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

This commit is contained in:
2026-10-15 23:42:08 +09:00
38 changed files with 9472 additions and 781 deletions
File diff suppressed because it is too large Load Diff
+184
View File
@@ -0,0 +1,184 @@
/* M02 일위대가 실무자료 확인 창(338-3) 헬퍼 — `Hopyo_Check` · `Hopyo_Choose` · `Hopyo_Ledger` · `Hopyo_Sheet`
* 도우미와 `Hopyo_Api` 길을 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 · sessionStorage 대역 ·
* stdin = {lines, rows, ledger} · stdout = {check, choose, ledger, sheet, calls, pick} JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
// DOM · 모듈 메타를 쓰는 창은 대역(도우미 · 길만 시험)
const STUB_FILES = [
"M02_MasterTemplete_Labor_Pick",
"M02_Material_Pick",
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
];
const opened = [];
const stub = new Proxy(
{},
{
get: (_t, k) =>
k === "currentLanguageIndex"
? () => 0
: k === "openModal"
? (o) => opened.push(o.title)
: () => {},
},
);
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (STUB_FILES.some((name) => request.endsWith(name))) return stub;
if (request.startsWith(".") && !request.endsWith(".css"))
return loadTs(`${path.resolve(path.dirname(full), request)}.ts`);
return stub;
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const store = new Map();
global.sessionStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
};
(async () => {
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const calls = [];
global.fetch = async (url, init = {}) => {
calls.push({
url,
method: init.method ?? "GET",
body: init.body ? JSON.parse(init.body) : null,
});
const body = url.includes("/hopyo-picks") || url.includes("/hopyo-ledger") ? { 줄: [] } : {};
return { status: 200, ok: true, json: async () => body };
};
const M02 = path.join(ROOT, "M02_MasterTemplete");
const check = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Check.ts"));
const choose = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
const ledger = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Ledger.ts"));
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts"));
const { lines } = input;
const scope = { layer: "personal", owner: null };
const key = check.draftKey(scope, "HP-000001", 3);
// 서버 제안 그대로 → 사용자가 만짐(체크 끔 · 뺌 · 고른 대상) → 캐시 다시 읽음
const fresh = check.loadDraft(key);
const suggested = check.bodyOf(lines, fresh);
const d = check.loadDraft(key);
d.체크[1] = false; // 맞음 줄 체크 끔
d.뺌[3] = "우리에 없음";
d.체크[3] = true;
d.대상[4] = [{ 로직: "GF000211", 이름: "고임돌", 입력값: { 유형: "보통" }, 배: 0.1, 몫: 0.1 }];
d.체크[4] = true;
check.saveDraft(key, d);
const again = check.loadDraft(key);
const ask = check.newest();
const a = ask(1);
const b = ask(1);
const c = ask(2);
const out = {
check: {
key,
suggested,
edited: check.bodyOf(lines, again),
leftOut: check.leftOut(lines, again),
checkable: lines.map((l) => check.checkable(l, fresh)),
sure: check.sureLines(lines, fresh).map((l) => l.차례),
compare: check.compareText(lines[1].맞춤.품),
toTarget: [
check.toTarget({ 호표: 46, 이름: "고화매트", 입력값: {}, 배: 2.3 }),
check.toTarget({ 자재: "MT1", 이름: "레미콘", 입력값: {}, 배: 1, 몫: null }),
],
newest: [a(), b(), c()],
dropped: (check.dropDraft(key), store.has(key)),
},
choose: {
hint: [
choose.searchHint({
이름: "레미콘타설(장비사용)",
근거: { 출처: [["건설품셈", "6-1-1"]], 직종: [], 행: [1, 1] },
}),
choose.searchHint({ 이름: "(소)고임돌 채집 및 운반" }),
],
picked: choose.pickedTargets([
{ c: { 로직: "GC000206" }, 몫: null },
{ c: { 로직: "GF000211" }, 몫: 0.1 },
]),
inputs: choose.inputsText({ 소량감률: 50, 지역: "서울" }, ["측압"], {
소량감률: "블록",
지역: "자동값",
}),
word: [choose.builtWord({ 다시씀: false }), choose.builtWord({ 다시씀: true })],
qty: choose.qtyText(lines[1].맞춤.품.엑셀),
target: choose.targetText({ 호표: 46, 단위: "㎡" }),
},
ledger: (() => {
const rows = input.ledger;
const edits = {
"가|b|m3": null, // 지움
"나||m3": { 원래: { 품명: "나", 규격: "", 단위: "m3" }, 갈래: "안 씀", 짝: [] },
"다||개": ledger.entryOf(rows[2]), // 위 층 → 내 층
};
const merged = ledger.mergeRows(rows, edits, { layer: "personal", owner: "7/42" });
return {
key: ledger.ledgerKey({ layer: "company", owner: "7" }),
merged: merged.map((r) => [r.열쇠, r.층, r.고침, !!r.고친, r.갈래]),
filter: ledger.filterRows(merged, "MT1", "").map((r) => r.열쇠),
kind: ledger.filterRows(merged, "", "안 씀").map((r) => r.열쇠),
pair: ledger.pairText({ 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 1, 몫: 0.1 }),
entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }),
};
})(),
sheet: {
order: sheet.buildOrder([
{ 번: 45, 아래: [46] },
{ 번: 46, 아래: [] },
{ 번: 3, 아래: [] },
{ 번: 7, 아래: [8] },
{ 번: 8, 아래: [7] },
]),
},
};
// 길 — 만들기(줄 · 통째) · 호표 하나(만들 층) · 미리 셈 · 장부
const from = { 층: "system", 주인: null, 키: "HP-000001", 번: 3 };
const target = { layer: "company", owner: "7" };
await api.buildHopyo(target, from, suggested);
await api.buildHopyo(target, from, [], { 대상: [{ 로직: "GF000225", 배: 1 }] });
await api.readHopyoOne({ layer: "system", owner: null }, "HP-000001", 3, target);
await api.guessHopyo(scope, "HP-000001", 3, 4, [
{ 로직: "GF000211", 입력값: {}, 배: 0.1, 몫: 0.1 },
]);
await api.readLedger(target);
await api.saveLedger(target, { "가|b|m3": null });
out.calls = calls.splice(0);
// [로직 추가] 실무 호표 — 확인된 것 = 바로 참조 · 아닌 것 = 확인 창(만들기 길 안 부름)
const picked = [];
const lib = {
layer: "company",
owner: "7",
pick: (ref, name, alias) => picked.push({ ref, name, alias }),
};
for (const row of input.rows) await pick.pickHopyo(row, lib);
await new Promise((r) => setTimeout(r, 0));
out.pick = { picked, calls: calls.splice(0), opened };
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
+254
View File
@@ -0,0 +1,254 @@
"""338 골든 도우미 — 실물 엑셀(`resources/knowledge/original/실무문서`)을 지금 읽기로 읽어 정답 표
(`fixtures/m02_hopyo_golden.json`)와 맞대고 지표(설계 7-3)를 셈. 읽기만.
블록 글 = HP 블록에 `글` 이 있으면 그것(나 창 2a 뒤) · 없으면 단가산출 시트 산출근거 열 글(엑셀꼴) ·
거창 = 줄 칸을 이어 붙임. STmate 파일은 읽기(`M02_Hopyo_Excel.read_book`)가 되면 들어감 · 아니면 뺌.
python resources/tester/helper_m02_hopyo_golden.py → 지표 찍음 · tmp/m02_338/golden.json
"""
from __future__ import annotations
import json
import sys
import time
from functools import lru_cache
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from M02_MasterTemplete import M02_Hopyo_Basis as hb # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Excel as hx # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Match as hm # noqa: E402
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict # noqa: E402
DOCS = ROOT / "resources/knowledge/original/실무문서"
FIXTURE = Path(__file__).resolve().parent / "fixtures/m02_hopyo_golden.json"
FILES = {
"북부청임도": DOCS
/ "2026_실무일위대가 모음/2026년_북부지방산림청_임도사업_설계내역서(공통단가).xls",
"북부청사방": DOCS
/ "2026_실무일위대가 모음/2026년_북부지방산림청_사방사업(계류+댐)_설계내역서.xls",
"거창": DOCS
/ "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
"봉화": DOCS
/ "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"동부청영월": DOCS / "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"울진대흥": DOCS
/ "2024년 간선임도(기번3-울진.대흥)(공통)"
/ "2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
"영덕계류": DOCS
/ "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료"
/ "2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
"울진소광": DOCS
/ "25년 산불진화임도(기번8)(울진소광)"
/ "2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
}
LABELED = ("북부청임도", "북부청사방", "동부청영월")
def labels() -> list[dict[str, Any]]:
return json.loads(FIXTURE.read_text(encoding="utf-8"))
def _row_text(row: list, start: int) -> str:
cells = []
for v in row[start:140]:
if v is None or v == "":
continue
cells.append(str(int(v) if isinstance(v, float) and v == int(v) else v).strip())
return " ".join(cells)
@lru_cache(maxsize=8)
def read(tag: str) -> dict[str, Any] | None:
"""파일 → HP 꼴(블록 `글` 붙임) · 못 읽으면 None(STmate 읽기 전)."""
path = FILES[tag]
data = path.read_bytes()
try:
book = hx.read_book(path.name, data)
except hx.ExcelError:
return None
if any(b.get("글") or b.get("자원") for b in book.get("단산") or []):
return book
calc = hx._sheets(path.name, data).get(hb.SHEET) or []
_, col, _ = hb._columns(calc)
join = tag == "거창"
for b in book.get("단산") or []:
a, z = b["행"]
rows = calc[a:z]
text = [_row_text(r, col) if join else (r[col] if col < len(r) else "") for r in rows]
b["글"] = [str(t).rstrip() for t in text if isinstance(t, str) and t.strip()]
return book
def _key(line: dict[str, Any]) -> tuple:
ref = line.get("참조") or {}
no = ref.get("번") if ref.get("종류") == "단산" else None
code = str(ref.get("코드") or line.get("코드") or "")
return line.get("이름"), line.get("규격") or "", line.get("단위") or "", no, code[:6]
def _lab_key(lab: dict[str, Any]) -> tuple:
return lab["이름"], lab["규격"], lab["단위"], lab.get("단산"), str(lab.get("코드") or "")[:6]
def line_of(lid: str) -> tuple[dict | None, dict[str, Any]]:
"""정답 표 id → (블록, 줄) — 시험이 실물 줄 하나를 꺼냄."""
lab = next(x for x in labels() if x["id"] == lid)
book = read(lab["파일"])
blocks = {int(b["번"]): b for b in book.get("단산") or []}
for h in book["호표"]:
for line in h["줄"]:
if _key(line) == _lab_key(lab) or (
not line.get("참조") and _key(line)[:3] == _lab_key(lab)[:3]
):
ref = line.get("참조") or {}
return (blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None), line
raise KeyError(lid)
def evaluate(tags: tuple[str, ...] | None = None) -> dict[str, Any]:
"""{줄: {id: 결과}, 호표: {(파일, 번): (ids, refs)}, 읽음: [파일], 시간}."""
labs = labels()
tags = tags or tuple(dict.fromkeys(lab["파일"] for lab in labs))
got = hm.targets()
out: dict[str, Any] = {"줄": {}, "호표": {}, "읽음": [], "못읽음": [], "시간": {}}
for tag in tags:
book = read(tag)
if book is None:
out["못읽음"].append(tag)
continue
out["읽음"].append(tag)
want = {_lab_key(lab): lab for lab in labs if lab["파일"] == tag}
loose = {k[:3]: lab for k, lab in want.items()}
blocks = {int(b["번"]): b for b in book.get("단산") or []}
t0 = time.time()
for h in book["호표"]:
ids, refs = [], []
for line in h["줄"]:
ref = line.get("참조") or {}
if ref.get("종류") == "일위" and ref.get("번"):
refs.append(int(ref["번"]))
lab = want.get(_key(line)) or (loose.get(_key(line)[:3]) if not ref else None)
if lab is None:
continue
ids.append(lab["id"])
if lab["id"] in out["줄"]:
continue
block = blocks.get(int(ref["번"])) if ref.get("종류") == "단산" else None
out["줄"][lab["id"]] = {"lab": lab, "셈": verdict.block(block, line, got)}
out["호표"][f"{tag}|{h['번']}"] = (list(dict.fromkeys(ids)), refs)
out["시간"][tag] = round(time.time() - t0, 1)
return out
def chosen_of(entry: dict[str, Any]) -> set[str]:
"""줄이 고른 로직(T1) — 조합이면 체크한 것 · 아니면 1위."""
combo = entry.get("조합") or []
top = (entry.get("차례") or [None])[0]
codes = {c["로직"] for c in combo if c["로직"] and c.get("체크")}
return codes or ({top} if top else set())
def _gather(res: dict, key: str, seen: tuple = ()) -> list[str]:
tag = key.split("|")[0]
if key not in res["호표"] or key in seen:
return []
ids, refs = res["호표"][key]
out = list(ids)
for no in refs:
out += _gather(res, f"{tag}|{no}", (*seen, key))
return list(dict.fromkeys(out))
def metrics(res: dict[str, Any], group: set[str] | None = None) -> dict[str, Any]:
"""설계 7-3 지표 — 사람 정답 줄 · 새 파일 · 조합 맞음(T1 · 덮을 수 있는 호표)."""
rows = [r for r in res["줄"].values() if group is None or r["lab"]["무리"] in group]
have = [r for r in rows if r["lab"]["정답"]]
ok1 = lambda r: (r["셈"]["차례"] or [None])[0] in set(r["lab"]["정답"]) # noqa: E731
state = lambda r: r["셈"]["판정"]["상태"] # noqa: E731
opt_ok = 0
for r in have:
top = (r["셈"]["차례"] or [None])[0]
found = (r["셈"]["옵션"] or {}).get(top) or {}
if ok1(r) and found.get("수준", 9) <= 1 and state(r) == "맞음":
opt_ok += 1
out = {
"줄": len(have),
"1순위": sum(ok1(r) for r in have),
"다섯 안": sum(bool(set(r["셈"]["차례"][:5]) & set(r["lab"]["정답"])) for r in have),
"맞음": sum(state(r) == "맞음" for r in rows),
"맞음 틀림": sum(state(r) == "맞음" and not ok1(r) for r in rows),
"다른 점": sum(state(r) == "다른 점" for r in rows),
"다른 점 틀림": sum(state(r) == "다른 점" and not ok1(r) for r in rows),
"옵션까지": opt_ok,
}
keys = [k for k in res["호표"] if k.split("|")[0] in LABELED]
whole = cover = 0
by = res["줄"]
for key in keys:
ids = [
i
for i in _gather(res, key)
if i in by and (group is None or by[i]["lab"]["무리"] in group)
]
if not ids:
continue
slots, chosen = [], set()
for i in ids:
lab = by[i]["lab"]
if not lab["정답"]:
slots.append(set())
elif lab.get("복합") and lab["id"] != "S062":
slots += [{x} for x in lab["복합"]]
else:
slots.append(set(lab["정답"]))
chosen |= chosen_of(by[i]["셈"])
if any(not s for s in slots):
continue
cover += 1
alts = set().union(*slots)
whole += all(chosen & s for s in slots) and not (chosen - alts)
out["조합 호표"], out["조합 맞음"] = cover, whole
return out
def show(res: dict[str, Any]) -> dict[str, Any]:
table = {
"사람": metrics(res, {"사람"}),
"새 파일": metrics(res, {"자동", "씨앗", "자동+씨앗"}),
**{
tag: metrics(
{**res, "줄": {i: r for i, r in res["줄"].items() if r["lab"]["파일"] == tag}},
{"사람"},
)
for tag in LABELED
if tag in res["읽음"]
},
}
return {"읽음": res["읽음"], "못읽음": res["못읽음"], "시간": res["시간"], "지표": table}
if __name__ == "__main__":
t = time.time()
res = evaluate(tuple(sys.argv[1:]) or None)
summary = show(res)
print(json.dumps(summary, ensure_ascii=False, indent=1))
print("전체", round(time.time() - t, 1), "s")
dump = {
i: {
"lab": r["lab"],
"차례": r["셈"]["차례"],
"판정": r["셈"]["판정"],
"조합": r["셈"]["조합"],
"B": r["셈"]["B"],
}
for i, r in res["줄"].items()
}
(ROOT / "tmp/m02_338").mkdir(parents=True, exist_ok=True)
(ROOT / "tmp/m02_338/golden.json").write_text(
json.dumps(dump, ensure_ascii=False, indent=1, default=str), "utf-8"
)
+11 -25
View File
@@ -1,5 +1,5 @@
/* M02 일위대가 실무자료(324-4) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
* `M02_MasterTemplete_Hopyo_View.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를
/* M02 일위대가 실무자료(324-4 · 338-3) 헬퍼 — `M02_MasterTemplete_Hopyo_Pick.ts`(고르기 창 「실무 호표」 덧줄) ·
* `M02_MasterTemplete_Hopyo_Choose.ts` 글 도우미 · `M02_MasterTemplete_Hopyo_Sheet.ts` 엑셀 보기 창 도우미(329 · 행 → 호표)를
* 트랜스파일해 Node 로 돌림 · fetch 대역이 부른 길 · 몸을 모음 ·
* stdin = {rows, built} · stdout = {calls, kinds, rows, picked, project, view, sheet} JSON. */
const fs = require("fs");
@@ -8,8 +8,14 @@ const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "t
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
// 본 화면이 부르는 고르기 창 · 자재 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
// 고르기 창 · 자재 창 · 입력 창 · 원문 창은 DOM · 모듈 메타를 씀 — 대역(글 도우미만 시험)
const STUB_FILES = [
"M02_MasterTemplete_Labor_Pick",
"M02_Material_Pick",
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
];
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
const cache = new Map();
function loadTs(file) {
@@ -55,7 +61,7 @@ function loadTs(file) {
const rows = (await wrapped.more()).map(({ onPick: _, ...r }) => r);
await pick.pickHopyo(input.rows[0], lib);
const project = pick.withHopyo({ ...lib, layer: "project" });
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
await api.readHopyoGrid({ layer: "company", owner: "7" }, "HP-000001");
@@ -76,17 +82,6 @@ function loadTs(file) {
view.targetText({ 호표: 46, 단위: "㎡" }),
],
inputs: view.inputsText({ 횟수: "4회사용시", 지역: "서울" }, ["보정작업"]),
memory: [
view.memoryOf({ 로직: "GF000101" }, { 지역: "서울" }, 1),
view.memoryOf({ 자재: "MT1" }, {}, 0.5),
view.memoryOf({ 로직: "GF000101" }, {}, 0),
view.memoryOf(null, {}, 1),
],
word: [
view.builtWord({ 다시씀: false }, false),
view.builtWord({ 다시씀: true }, false),
view.builtWord({ 다시씀: true }, true),
],
},
basis: (() => {
const hit = {
@@ -99,9 +94,6 @@ function loadTs(file) {
const half = { ...hit, 직종: [] };
const trade = { 출처: [["건설품셈", "3-2-1"]], 직종: ["특별인부", "보통인부"], 행: [1, 1] };
const plain = { ...trade, 직종: ["보통인부"] };
const line = (상태, 대상, 후보) => ({ 근거: trade, 맞춤: { 상태, 대상, 후보 } });
const a = { 로직: "GF000101", 근거: half };
const b = { 로직: "GC000083", 근거: hit };
return {
ok: [hit, half, { ...hit, 단위: false }, { ...hit, 까닭: "앞 마디 같음" }, undefined].map(
(b) => view.basisOk(b, trade),
@@ -114,12 +106,6 @@ function loadTs(file) {
view.sourceText({ 출처: [], 직종: [], 행: [7, 7] }),
view.sourceText(undefined),
],
pick: [
line("후보", a, [a, b]),
line("후보", b, [b]),
line("같음", b, [b]),
line("후보", a, [a]),
].map((x) => view.basisPick(x)?.로직 ?? null),
};
})(),
sheet: {
+12 -10
View File
@@ -1,15 +1,21 @@
/* M02 일위대가 실무자료 이음매(329 · 330) 헬퍼 — 실제 서버 응답(격자 · 호표 맞춤)을
* `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_View.ts`
* (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음 대상)에 먹임 ·
* stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok, pick}],
* count: {top, pick, half}} JSON — count = 「후보」 줄 1순위 근거 맞음 · 근거 맞음 대상 · 그 가운데 앞 마디. */
* `M02_MasterTemplete_Hopyo_Sheet.ts`(행 → 호표 · 영역 A1) · `M02_MasterTemplete_Hopyo_Choose.ts`
* (근거 꼬리표 · 엑셀 근거 줄 · 근거 맞음)에 먹임 ·
* stdin = {grid, lines, all} · stdout = {at, span, rows, lines: [{이름, source, tag, ok}],
* count: {top}} JSON — count = 「후보」 줄 1순위 근거 맞음. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const STUBS = { "@config/config_frontend": { API_BASE_URL: "/api" } };
const STUB_FILES = ["M02_MasterTemplete_Labor_Pick", "M02_Material_Pick"];
const STUB_FILES = [
"M02_MasterTemplete_Labor_Pick",
"M02_Material_Pick",
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
];
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? () => 0 : () => {}) });
const cache = new Map();
function loadTs(file) {
@@ -34,7 +40,7 @@ function loadTs(file) {
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const M02 = path.join(ROOT, "M02_MasterTemplete");
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_View.ts"));
const view = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Choose.ts"));
const { grid } = input;
const ilwi = grid.책.시트.find((s) => s.이름 === "일위대가");
const three = grid.호표.find((h) => h.번 === 3);
@@ -49,15 +55,11 @@ const out = {
source: view.sourceText(line.근거),
tag: view.basisTag(top?.근거),
ok: view.basisOk(top?.근거, line.근거),
pick: view.basisPick(line)?.로직 ?? null,
};
}),
};
const open = (input.all ?? []).filter((line) => line.맞춤.상태 === "후보");
const picks = open.map((line) => view.basisPick(line)).filter(Boolean);
out.count = {
top: open.filter((line) => view.basisOk((line.맞춤.후보 ?? [])[0]?.근거, line.근거)).length,
pick: picks.length,
half: picks.filter((c) => !c.근거.까닭.startsWith("조항 같음")).length,
};
process.stdout.write(JSON.stringify(out));
+17
View File
@@ -4,6 +4,7 @@
후보 93 → GC000083 · 21 → GC000198 · 64 → GC000143 위 둘 · 49 → GC000085 ·
잔토처리에 철골재 철거 없음 · 근거 없는 줄 그대로 · 상태 「후보」(자동 확정 없음) ·
블록 글 HP(338-2a · 268 · 139 이음 그대로) · `clause` 윗마디 0.9 · 조항 오타 13-114 ·
거창 꼴 흩어진 칸 이어 붙임(단산 줄 21 · 출처 17 · 직종 21 · 338-2b) ·
master_data 그대로.
"""
@@ -152,3 +153,19 @@ def test_조항_윗마디_오타(north):
assert hb.sources("[산림사업표준품셈 9-14]") == [["산림품셈", "9-14"]]
stone = next(b for b in north["단산"] if b["번"] == 63)
assert stone["출처"] == [["산림품셈", "13-11-4"]]
def test_거창_흩어진_칸():
path = (
base.ROOT
/ "resources/knowledge/original/실무문서/01.산불거창(거창북상병곡)/01.내역서"
/ "00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx"
)
book = hx.read_book(path.name, path.read_bytes())
lines = [x for h in book["호표"] for x in h["줄"] if x["갈래"] == "단가산출"]
assert len(lines) == 21 and all(x.get("근거") for x in lines)
assert sum(1 for x in lines if x["근거"]["출처"]) == 17
assert sum(1 for x in lines if x["근거"]["직종"]) == 21
first = book["단산"][0]
assert first["이름"] == "측구터파기(토사)" and first["직종"] == ["보통인부"]
assert any(t.startswith("보통인부 : 172,068") for t in first["글"])
+53 -1
View File
@@ -2,7 +2,8 @@
견본 = `resources/knowledge/original/실무문서/2026_실무일위대가 모음/`(북부청 임도 · 사방) ·
검산 어긋남 0 · 호표 값 · 시트 없음 400 · xlsx 길 · 역할 표 · 엉터리 · 20 MB · 지우기 ·
양식 목록에 안 섞임 ·
양식 목록에 안 섞임 · 이음 넓힘(산근 · 대가 · 호표 · 이름으로 이음) · 노임 갈래 ·
문서 보안 400(338) ·
master_data 원본 그대로(sha256). 층은 임시 폴더.
"""
@@ -207,3 +208,54 @@ def test_마스터_층은_마스터_폴더(client):
client.who.update(role="USER")
assert client.get(URL.format("system")).json()["줄"][0]["키"] == "HP-000001" # 읽기 = 누구나
assert client.delete(URL.format("system") + "/HP-000001").status_code == 403
def _linked_xlsx() -> bytes:
"""338 — 비고 「산근 2 호표」 · 「대가1」 · 빈 비고 + 이름 같은 블록 · 노임 줄."""
import openpyxl
book = openpyxl.Workbook()
ws = book.active
ws.title = "일위대가"
ws.append([None, "호표", "품명", "규격", "수량", "단위", "합계", None, "노무비", None, "비고"])
ws.append([None, None, None, None, None, None, "단가", "금액", "단가", "금액"])
ws.append([None, "제1호표", "표지석", "", None, "개소", None, 1000, None, 1000])
ws.append([None, None, "터파기", "토사", 1, "㎥", 100, 100, 100, 100, "산근 2 호표"])
ws.append([None, None, "되메우기", "", 1, "㎥", 100, 100, 100, 100, None])
ws.append([None, None, "보통인부", "", 1, "인", 100, 100, 100, 100, None])
ws.append([None, None, "호표 줄", "", 1, "개소", 100, 100, 100, 100, "대가1"])
calc = book.create_sheet("단가산출")
calc.append([None, "산출근거", "합계", "노무비"])
calc.append([None, "2.터파기[㎥]"])
calc.append([None, " [건설공사표준품셈 3-2-1]"])
calc.append([None, "3.되메우기[㎥]"])
calc.append([None, " 보통인부 : 100 × 1 = 100"])
out = io.BytesIO()
book.save(out)
return out.getvalue()
def test_이음_넓힘_노임():
got = hx.read_book("a.xlsx", _linked_xlsx())
lines = {x["이름"]: x for x in got["호표"][0]["줄"]}
assert lines["터파기"]["참조"] == {"종류": "단산", "번": 2} and "이음" not in lines["터파기"]
assert lines["터파기"]["근거"]["출처"] == [["건설품셈", "3-2-1"]]
assert lines["되메우기"]["참조"] == {"종류": "단산", "번": 3} # 빈 비고 · 이름 같은 블록 하나
assert lines["되메우기"]["이음"] == "이름" and lines["되메우기"]["갈래"] == "단가산출"
assert lines["되메우기"]["근거"]["직종"] == ["보통인부"]
assert lines["보통인부"]["갈래"] == "노임" and "이음" not in lines["보통인부"]
assert lines["호표 줄"]["참조"] == {"종류": "일위", "번": 1}
for note, want in (
("단산12참조", ("단산", 12)),
("단가산출 3", ("단산", 3)),
("일위대가4호표", ("일위", 4)),
("일위 5 참조", ("일위", 5)),
):
hit = hx._REF.match(hx._flat(note))
assert ("단산" if hit.group(1) else "일위", int(hit.group(3))) == want
assert hx._REF.match("단산참조") is None and hx._REF.match("D96") is None
def test_문서_보안_400():
with pytest.raises(hx.ExcelError, match="문서 보안"):
hx.read_book("a.xlsx", bytes([0x9B]) + b" DRMONE This Document is secured" + bytes(64))
+147
View File
@@ -0,0 +1,147 @@
"""338-1 엑셀 지문(`M02_Hopyo_Finger` · `_Words` · `_Shares`) — 식 풀기 일곱 꼴 · 합판 지문 · 고임돌
섞음 ·
몫 · STmate 자원 · 기계 줄 · 직접 줄 호표. 실물 = 북부청 임도(읽기만)."""
import pytest
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete.M02_Hopyo_Words import conds, mtypes, parse_rhs
from resources.tester.helper_m02_hopyo_golden import line_of
approx = pytest.approx
# ── 식 풀기 ────────────────────────────────────────────────────────────
def test_식_단가_떼고_품():
got = parse_rhs("273,540 × 3인 ÷ 55㎥ × 2 = 29,840.7")
assert got["단가"] == 273540 and got["품"] == approx(0.10909, abs=1e-5) and got["율"] == []
def test_식_퍼센트_율():
got = parse_rhs("59,020 ÷ 23.1 × 90% = 2,299.5")
assert got["품"] == approx(0.03896, abs=1e-5)
assert got["밑품"] == approx(1 / 23.1) and got["율"] == [[0.9, "%"]]
def test_식_인당_단위_섞음():
got = parse_rhs("172,068 × 0.3인/㎥ × 10% = 5,162")
assert got["품"] == approx(0.03) and got["밑품"] == approx(0.3)
def test_식_바꿔_쓴_식은_앞_율을_이어받음():
got = parse_rhs("215,907 × 4인 ÷ P × 1.2(할증) = 215,907 × 0.104 × 1.2 = 26,945")
assert got["품"] == approx(0.1248) and got["밑품"] == approx(0.104)
assert got["율"] == [[1.2, "할증"]]
def test_식_횟수_품할증():
got = parse_rhs("172,068 × 0.05인 × 2회 = 17,206")
assert got["품"] == approx(0.1) and got["밑품"] == approx(0.05) and got["율"] == [[2.0, "회"]]
got = parse_rhs("273,540 × 0.1 × 1.2(품할증) = 32,824")
assert got["밑품"] == approx(0.1) and got["율"] == [[1.2, "품할증"]]
def test_식_못_풀면_역산():
assert parse_rhs("273,540 × P = 30,000") is None
block = {"번": 1, "이름": "시험", "단위": "㎥", "글": [" 특별인부 : 273,540 × P = 27,354"]}
fp = finger.excel(block, {"이름": "시험", "규격": "", "단위": "㎥"})
assert fp["인력"]["특별인부"]["품"] == approx(0.1) and fp["인력"]["특별인부"]["어떻게"] == [
"역산"
]
def test_장비_갈래_조건_낱말():
assert mtypes("유압식 백호우 0.7㎥") == {"굴착기"} and mtypes("펌프카 41m") == {"콘크리트펌프"}
assert conds("합판거푸집 4회 보통")["횟수"] == {"4회"}
assert conds("구조물터파기(암)")["토질"] == {"암"}
# ── 실물 지문 ──────────────────────────────────────────────────────────
def test_합판_4회_지문():
block, line = line_of("N001")
fp = finger.excel(block, line)
assert fp["인력"]["형틀목공"]["품"] == approx(0.1111, abs=1e-4)
assert fp["인력"]["보통인부"]["품"] == approx(0.0444, abs=1e-4)
assert "4회" in fp["조건"]["횟수"] and fp["출처"] == [["건설품셈", "6-3-1"]]
assert {m["이름"][:4] for m in fp["자재"] if m["품"]} >= {"내수합판"}
def test_레미콘_소량_지문_장비_시간():
block, line = line_of("N000")
fp = finger.excel(block, line)
assert fp["인력"]["콘크리트공"]["품"] == approx(0.10909, abs=1e-5)
assert [(m["갈래"], round(m["시간"], 2)) for m in fp["장비"]] == [(["굴착기"], 0.29)]
def test_고임돌_섞음_율과_몫():
block, line = line_of("N005")
fp = finger.excel(block, line)
labor = fp["인력"]["보통인부"]
assert labor["밑품"] == approx(0.3, abs=1e-3) and labor["품"] == approx(0.03, abs=1e-3)
assert any(note in ("%", "섞음") and v == approx(0.1) for v, note in labor["율"])
parts = fp["몫"]
assert any(p["노임"] and not p["장비"] for p in parts) # 인력 몫
assert any(p["장비"] and not p["노임"] for p in parts) # 장비 몫
def test_몫_절마다_노임_장비():
texts = [
" 1. 굴착기 0.6㎥ 채집(90%)",
" 노무비 : 59,020 ÷ 23.1 × 90% = 2,299",
" 2. 인력채집(10%)",
" 보통인부 : 172,068 × 0.3인/㎥ × 10% = 5,162",
" 3. 합 계",
" 보통인부 : 172,068 × 1 = 172,068",
]
parts = shares.sections(texts)
assert [p["제목"] for p in parts] == ["굴착기 0.6㎥ 채집(90%)", "인력채집(10%)"]
assert parts[0]["장비"] == {"굴착기|0.6": approx(0.9 / 23.1)}
assert parts[1]["노임"] == {"보통인부": approx(0.03)}
def test_STmate_자원_지문():
block = {
"글": ["잔토처리 (기계100%) 건설표준품셈9-3", "q = 0.7 , K = 0.9"],
"자원": [
{"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.2},
*[
{
"갈래": "X",
"코드": "X00205",
"이름": "굴삭기(무한궤도)",
"규격": "0.7㎥",
"품": 0.0229,
}
]
* 3,
{"갈래": "M", "코드": "M00001", "이름": "모래", "규격": "", "품": 1.2},
],
}
fp = finger.excel(block, {"이름": "잔토처리", "규격": "토사", "단위": "M3"})
assert fp["인력"] == {"보통인부": {"품": 0.2, "밑품": 0.2, "율": [], "어떻게": ["QTY"]}}
assert [(m["갈래"], m["규격"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], [0.7], 0.0229)]
assert fp["자재"] == [{"이름": "모래", "품": 1.2, "밑품": 1.2}] and fp["계수"]["K"] == 0.9
def test_기계_줄_블록_없음():
fp = finger.excel(None, {"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["콘크리트펌프"], 1.0)]
assert 41.0 in fp["장비"][0]["규격"] and fp["인력"] == {}
def test_직접_줄_호표():
hopyo = {
"이름": "돌붙임",
"규격": "",
"단위": "㎡",
"줄": [
{"이름": "석 공", "규격": "", "수량": 0.1, "단위": "인", "갈래": "기타"},
{"이름": "보통인부", "규격": "", "수량": 0.05, "단위": "인", "갈래": "기타"},
{"이름": "굴착기", "규격": "0.6㎥", "수량": 0.2, "단위": "hr", "갈래": "기계"},
],
}
fp = finger.direct(hopyo)
assert fp["인력"]["석공"]["품"] == 0.1 and fp["인력"]["보통인부"]["품"] == 0.05
assert [(m["갈래"], m["시간"]) for m in fp["장비"]] == [(["굴착기"], 0.2)]
assert finger.direct({**hopyo, "줄": hopyo["줄"][2:]}) is None # 노임 없으면 호표 통째 아님
+122
View File
@@ -0,0 +1,122 @@
"""338-1 순위 · 판정 · 조합 · 뒤 셈(`M02_Hopyo_Fit` · `_Verdict` · `_Combine`) — 같음 규칙 · 딱
맞음이
같음을 이김 · 맞음 · 다른 점 · 후보 · 철근 둘 · 고임돌 인력 10% · 맞춤셈 캐시 · 「셈 중」.
실물 = 북부청 임도 · 사방(읽기만) · 층 · 캐시 폴더는 임시."""
import shutil
import time
import pytest
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Hopyo_Verdict as verdict
from resources.tester.helper_m02_hopyo_golden import line_of, read
from resources.tester.test_m02_hopyo_excel import master_hash
@pytest.fixture(scope="module")
def got():
if not ours.ready():
ours.build()
return hm.targets()
def _block(lid: str, got: dict, **change) -> dict:
block, line = line_of(lid)
if change:
line = {**line, "근거": {}}
block = {**block, "출처": []}
return verdict.block(block, line, got)
def test_같음_규칙_이름_같음_1위_고정(got):
r = _block("N014", got)
assert r["B"] == "같음" and r["차례"][0] == "GC000214" and r["판정"]["상태"] == "맞음"
def test_딱_맞음이_같음을_이김(got):
r = _block("N001", got, 근거=None) # 인용 조항을 지우면 이름 같음 = 산림 GF000160
assert r["B"] == "같음" and hm.b_order(line_of("N001")[1], got)["상태"] == "후보"
assert r["차례"][0] == "GC000211" and r["판정"]["상태"] == "맞음"
def test_판정_셋(got):
a = _block("N000", got)
assert a["판정"]["상태"] == "맞음" and "소량감률 50" in a["판정"]["글"]
assert a["후보"][0]["판정"] == "맞음" and a["후보"][0]["근거"]["까닭"] == "조항 같음"
b = _block("N008", got)
assert b["판정"]["상태"] == "다른 점" and b["차례"][0] == "GC000083"
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in b["판정"]["꼬리표"]
c = _block("S084", got)
assert c["판정"]["상태"] == "후보" and len(c["후보"]) == 5
def test_합판_맞음_못_가름_꼬리표(got):
r = _block("N001", got)
assert r["판정"]["상태"] == "맞음"
assert any(
t.endswith("못 가름") and {"측압", "폼타이규격"} <= set(t.split())
for t in r["판정"]["꼬리표"]
)
def test_조합_철근_가공_조립(got):
r = _block("N003", got)
assert {c["로직"] for c in r["조합"] if c["체크"]} == {"GC000206", "GC000207"}
def test_조합_고임돌_인력_몫_먼저(got):
r = _block("N005", got)
assert r["차례"][0] == "GF000211"
labor = next(c for c in r["조합"] if c["로직"] == "GF000211")
assert labor["몫"] == 0.1 and labor["체크"]
assert all(not c["체크"] for c in r["조합"] if c["로직"] in ("GC000267", "GC000268"))
def test_숫자_없음_옛_HP(got):
block, line = line_of("N000")
r = verdict.block({**block, "글": []}, line, got)
assert r["판정"]["상태"] == "후보" and r["판정"]["글"] == fit.NO_NUMBER
assert r["차례"][0] == "GC000198" # 이름 · 근거 차례 그대로
@pytest.fixture
def layer(tmp_path, monkeypatch, got):
cache = tmp_path / "cache"
cache.mkdir()
shutil.copy(ours.path_of(), cache / ours.path_of().name)
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(cache))
book = read("북부청임도")
small = {**book, "호표": book["호표"][:3]}
brief = hopyo_store.save(tmp_path, "북부청.xls", small, {"id": "t"})
return tmp_path, brief["키"], cache
def test_뒤_셈_캐시_셈_중(layer):
folder, key, cache = layer
before = master_hash()
assert fit.result(folder, key) is None
first = fit.ensure(folder, key)
assert first["끝"] is False and first["한"] == 0
for _ in range(300):
if fit.status(folder, key)["끝"]:
break
time.sleep(0.2)
done = fit.status(folder, key)
assert done["끝"] and done["한"] == done["모두"] > 0
got = fit.result(folder, key)
assert got["판"] == ours.version() and len(got["블록"]) == done["모두"]
assert fit.cache_path(folder, key).parent == cache
assert fit.ensure(folder, key)["끝"] # 두 번째는 캐시만 봄
assert master_hash() == before
def test_셈_단위_열쇠(got):
book = read("북부청임도")
units = fit.units({**book, "호표": book["호표"][:3]})
keys = [k for k, _, _ in units]
assert len(keys) == len(set(keys)) and keys[0].startswith("단산:")
assert all(k.startswith(("단산:", "줄:", "호표:")) for k in keys)
+20
View File
@@ -2,6 +2,7 @@
북부청 임도 xls — 병합 11 · 15열 · B20 「제3호표」 · `#,##0` · 호표 68 행 범위 안 겹침 ·
제3호표 [20, 24] · 올리면 격자도 · 200 / 옛 파일 404 · 지우면 격자도 · 역할 · xlsx 값만 ·
STmate 격자(일위대가표 · 단가산출근거 · 숨은 칸 글 뺌 · 338) ·
master_data 그대로.
"""
@@ -131,3 +132,22 @@ def test_xlsx_값만():
assert sheet["칸"]["B5"] == {"값": "제 1 호표"} and sheet["칸"]["E6"] == {"값": 0.5}
assert "K6" not in sheet["칸"] # 머리 글(앞 4 줄) 마지막 열 J 까지
assert grid["호표"] == [{"번": 1, "이름": "표지석", "행": [5, 6]}] and grid["단산"] == []
def test_STmate_격자():
from resources.tester.test_m02_hopyo_stmate import _path
path = _path("영월")
data = path.read_bytes()
parsed = hx.read_book(path.name, data)
grid = hg.read(path.name, data, parsed)
assert [s["이름"] for s in grid["책"]["시트"]] == ["일위대가표", "단가산출근거"]
cells = _sheet(grid, "일위대가표")["칸"]
one = grid["호표"][0]
assert one == {"번": 1, "이름": "녹생토(종자배합)", "행": [5, 10]}
assert cells[f"A{one['행'][0]}"]["값"].strip().startswith("제")
assert cells["A10"]["값"] == "보통인부"
calc = _sheet(grid, "단가산출근거")["칸"]
block = next(b for b in grid["단산"] if b["번"] == 2)
assert calc[f"B{block['행'][0] + 1}"]["값"].startswith("구조물터파기(인력)")
assert not any(str(c.get("값")).startswith("_x000") for c in {**cells, **calc}.values())
+63
View File
@@ -0,0 +1,63 @@
"""338-1 옵션 거꾸로 찾기(`M02_Hopyo_Option`) — 레미콘 소량 「하나만」 · 합판 갈리는 칸 · 무근 R
맞음 ·
구조물터파기 없음(다른 점) · 펌프차 기계 칸 · 견줌 · 반올림 폭. 실물 = 북부청 임도 ·
사방(읽기만)."""
import pytest
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Option as option
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from resources.tester.helper_m02_hopyo_golden import line_of
def _find(lid: str, code: str) -> dict:
block, line = line_of(lid)
return option.find(code, finger.excel(block, line), (block or {}).get("글"))
def test_레미콘_소량_하나만():
got = _find("N000", "GC000198")
assert got["수준"] == 0 and got["판정"] == "하나만"
assert got["입력"] == {"타설방법": "장비사용타설", "구조물": "철근구조물", "소량감률": 50}
assert got["우리품"]["인력"] == {"콘크리트공": 0.109091, "보통인부": 0.036364}
def test_합판_보통_갈리는_칸():
got = _find("N001", "GC000211")
assert got["수준"] == 0 and got["판정"] == "여럿(같은 품)"
assert got["입력"]["유형"] == "보통" and got["입력"]["사용횟수"] == "4회"
assert set(got["갈리는칸"]) == {"측압", "폼타이규격"}
def test_무근_반올림_맞음():
got = _find("N013", "GC000198")
assert got["수준"] <= 1 and got["입력"]["구조물"] == "무근구조물"
def test_구조물터파기_없음_가까운_옵션():
got = _find("N008", "GC000083")
assert got["수준"] == 9 and got["판정"] == "없음" and got["직종같음"]
assert got["견줌"]["직종"] == "직종 같음" and got["거리"] < 0.1
def test_펌프차_기계_칸():
efp = finger.machine({"이름": "콘크리트 펌프차", "규격": "41m,80~95㎥/hr", "단위": "hr"})
got = option.find("GC000268", efp)
assert got["수준"] == 0
code = got["입력"]["기계"]
assert ours.eq_of(code)[0] == {"콘크리트펌프"} and 41.0 in option.eq_numbers(code)
def test_없는_로직_못_셈():
efp = finger.machine({"이름": "굴착기", "규격": "0.6㎥", "단위": "hr"})
assert option.find("없는로직", efp)["판정"] == "못 셈"
def test_반올림_폭_견줌():
assert option.ru(0.15) == pytest.approx(0.005) and option.ru(0.1) == 0.0
assert option.ru(0.109091) == 0.0
e = {"인력": {"보통인부": {"품": 0.15, "밑품": 0.15}}, "장비": [], "자재": []}
v = {"L": {"보통인부": 0.1545}, "X": [], "M": {}, "S": {}}
assert not option.compare(e, v, 0.005)["핵심"] and option.compare(e, v, 0.005, rnd=True)["핵심"]
assert not option.strong(e, option.compare(e, v, 0.03)) # 보통인부 하나뿐 = 우연 맞음
+77
View File
@@ -0,0 +1,77 @@
"""338-1 우리 로직 지문(`M02_Hopyo_Ours`) — 판 열쇠 · 캐시 쓰기 · 읽기 · 준비 전 None · 합판 품 ·
기계경비 로직 · 섞기 전 변형 · 엔진 셈 캐시 · master_data 원본 그대로. 캐시 폴더는 임시."""
import pytest
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Match as hm
from M02_MasterTemplete import M02_Hopyo_Ours as ours
from resources.tester.test_m02_hopyo_excel import master_hash
approx = pytest.approx
@pytest.fixture
def cache(tmp_path, monkeypatch):
"""임시 캐시 폴더 + 로직 셋만 셈(1,438 대신) — 끝나면 메모리 표를 비움."""
monkeypatch.setenv("AISLO_HOPYO_CACHE", str(tmp_path))
few = [x for x in hm.targets()["로직"] if x["로직"] in ("GC000211", "GC000198", "GC000268")]
monkeypatch.setattr(hm, "targets", lambda: {**hm._targets(id(ours.whole())), "로직": few})
monkeypatch.setitem(ours._STATE, "표판", None)
monkeypatch.setitem(ours._STATE, "표", None)
yield tmp_path
ours._STATE.update(표판=None, 표=None)
def test_판_열쇠_같음():
a, b = ours.version(), ours.version()
assert a == b and len(a) == 16 and ours.path_of(a).name == f"우리지문_{a}.json"
def test_캐시_쓰기_읽기_준비_전_none(cache):
before = master_hash()
assert ours.table() is None and not ours.ready()
built = ours.build()
assert [o["로직"] for o in built] == ["GC000198", "GC000211", "GC000268"]
assert ours.path_of().parent == cache and ours.path_of().is_file()
ours._STATE.update(표판=None, 표=None)
assert [o["로직"] for o in ours.table()] == ["GC000198", "GC000211", "GC000268"]
assert master_hash() == before
def test_데움_뒤_스레드(cache):
thread = ours.warm(build_missing=True)
thread.join(timeout=120)
assert ours.ready() and len(ours.table()) == 3
def test_합판_보통_형틀목공():
fp = ours.fingerprint("GC000211", "㎡")
plain = next(v for v in fp["변형"] if v["in"].get("유형") == "보통")
assert plain["L"]["형틀목공"] == approx(0.112, abs=1e-3)
assert plain["L"]["보통인부"] == approx(0.045, abs=1e-3)
assert fp["직종"] == ["보통인부", "형틀목공"] and not fp["기계경비"]
assert fp["고름조건"]["횟수"] # 고르기 글 「4회사용시」 낱말
def test_기계경비_로직은_고른_기계_1시간():
v = ours.run("GC000268", {})
assert v["L"] == {} and len(v["X"]) == 1 and v["X"][0][2] == 1.0
assert ours.fingerprint("GC000268", "hr")["기계경비"]
def test_섞기_전_변형():
table = ours.table() or ours.build()
mixed = next(
o
for o in table
if any(0 < ((v.get("mix") or {}).get("인력비율") or 0) < 100 for v in o["변형"])
)
prep = fit._prep(mixed, {})
assert any(v["in"] and v["in"].get("_섞기전") for v in prep["vs"])
def test_엔진_셈_캐시():
a = ours.run("GC000198", {"소량감률": 50})
assert ours.run("GC000198", {"소량감률": 50}) is a # 같은 입력은 다시 안 셈
assert ours.run("없는로직", {}) is None
+279 -147
View File
@@ -1,12 +1,15 @@
"""일위대가 실무자료 화면(PLAN 324-4) — 좌측 판 · 본 화면 · [로직 추가] 「실무 호표」 줄.
"""일위대가 실무자료 화면(PLAN 324-4 · 338-3) — 좌측 판 · 본 화면 · 확인 창 · 고르기 창 ·
별칭 장부 · [로직 추가] 「실무 호표」 줄 · 엑셀 보기 창.
- 고르기 창 덧줄(Node · fetch 대역): 이 층 · 위 층 호표 줄(맞춤 n/m) · 종류 고르기 ·
고르면 문서 층에 만들기 길 → `lib.pick` 참조 줄(별칭 = 호표 품명) · 프로젝트 층엔 없음 ·
본 화면 글 도우미
- 고르기 창 근거(330-3): 꼬리표 · 엑셀 근거 줄 · [근거 맞음 모두 확정] 대상
- 엑셀 보기 창(329): 행 → 호표 · 호표 영역 A1 · 격자 길 · 여는 자리 넷 · 늦게 불러옴
- 소스: 좌측 판 자리(사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 올리기 꺼짐 ·
숨은 file 입력) · Labor_Detail 줄 수 그대로 · 새 파일 이름 · 700 줄 · 색 = 공용 토큰
- 확인 창 도우미(Node · fetch · sessionStorage 대역 · `helper_m02_hopyo_check.cjs`):
체크 → 몸(체크한 줄만 · 뺌 · 고른 대상 · 몫 · 배 · 호표) · 빠질 줄 까닭 · 캐시 키 ·
최신 응답만 · 품 비교 · 만들기 · 통째 · 만들 층 · 미리 셈 · 장부 길 · 장부 화면 합침 ·
하위 호표 먼저 · 확인된 것 = 바로 참조
- 고르기 창 덧줄(`helper_m02_hopyo_pick.cjs`): 이 층 · 위 층 호표 줄 · 종류 고르기 ·
프로젝트 층엔 없음 · 글 도우미 · 근거 꼬리표 · 엑셀 보기 창 행 → 호표
- 소스: 옛 즉시 확정 · 바로 만들기 없음(saveHopyoMemory · [확정] · [같음 모두 확정] ·
[근거 맞음 모두 확정] · [사전조합 만들기]) · `buildHopyo(` 는 확인 창 · 엑셀 보기 창 펼침에서만 ·
좌측 판 자리 · 700 줄 · 색 = 공용 토큰
"""
import json
@@ -17,45 +20,181 @@ from pathlib import Path
HERE = os.path.dirname(os.path.abspath(__file__))
M02 = Path(HERE).parents[1] / "M02_MasterTemplete"
NEW = ("Hopyo_Api", "Hopyo_Pick", "Hopyo_Side", "Hopyo_View", "Hopyo_Sheet")
NEW = (
"Hopyo_Api",
"Hopyo_Pick",
"Hopyo_Side",
"Hopyo_View",
"Hopyo_Sheet",
"Hopyo_Check",
"Hopyo_Choose",
"Hopyo_Ledger",
)
ROW = {
"층": "system",
"주인": None,
"키": "HP-000001",
"파일": "북부청.xls",
"번": 3,
"이름": "임도표지석",
"규격": "910×600×150",
"단위": "개소",
"맞춤": [2, 4],
}
SURE = {"로직": "GC000198", "이름": "장비사용타설", "입력값": {"소량감률": 50}, "배": 1, "몫": None}
QTY = {
"엑셀": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.29}]},
"우리": {"인력": {"콘크리트공": 0.1091}, "장비": [{"갈래": "굴착기", "시간": 0.31}]},
"같음": {"콘크리트공": True, "굴착기": False},
}
def _text(name: str) -> str:
return (M02 / name).read_text(encoding="utf-8")
def _run() -> dict:
row = {
"층": "system",
"주인": None,
"키": "HP-000001",
"파일": "북부청.xls",
"번": 3,
"이름": "임도표지석",
"규격": "910×600×150",
"단위": "개소",
"맞춤": [2, 4],
}
built = {
"참조": {"층": "company", "주인": "7", "키": "SC-000009"},
"이름": "임도표지석",
"다시씀": False,
"빠진줄": [{"차례": 0}],
"비교": {},
}
def _node(helper: str, payload: dict) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_hopyo_pick.cjs")],
input=json.dumps({"rows": [row], "built": built}, ensure_ascii=False),
["node", os.path.join(HERE, helper)],
input=json.dumps(payload, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_고르기_창_실무_호표_줄과_고름():
got = _run()
def _line(no, state, check, parts, **extra):
match = {"열쇠": f"줄{no}||m3", "상태": state, "체크": check, "대상": parts, **extra}
return {"차례": no, "이름": f"줄{no}", "규격": "", "수량": 1, "단위": "㎥", "갈래": "단가산출",
"맞춤": match} # fmt: skip
def _check() -> dict:
lines = [
_line(0, "후보", False, [{"자재": "MT004258", "입력값": {}, "배": 1}]),
_line(1, "맞음", True, [SURE], 품=QTY),
_line(2, "별칭", True, [{"로직": "GF000101", "입력값": {}, "배": 1}]),
_line(3, "다른 점", False, [{"로직": "GF000103", "입력값": {}, "배": 1}]),
_line(4, "후보", False, []),
_line(5, "%", False, []),
_line(6, "호표", True, [{"호표": 46, "입력값": {}, "배": 2.3}]),
_line(7, "노임", False, [{"로직": "GF000300", "입력값": {}, "배": None}]),
]
ledger = [
{"열쇠": "가|b|m3", "원래": {"품명": "가", "규격": "b", "단위": "m3"}, "갈래": "짝",
"짝": [{"로직": "GC000198", "입력값": {}, "배": 1, "몫": None}], "층": "personal",
"주인": "7/42", "고침": True},
{"열쇠": "나||m3", "원래": {"품명": "나", "규격": "", "단위": "m3"}, "갈래": "짝",
"짝": [{"로직": "GF000101", "입력값": {}, "배": 1, "몫": None}], "층": "personal",
"주인": "7/42", "고침": True},
{"열쇠": "다||개", "원래": {"품명": "다", "규격": "", "단위": "개"}, "갈래": "짝",
"짝": [{"자재": "MT1", "입력값": {}, "배": 1, "몫": None}], "층": "system",
"주인": None, "고침": False},
] # fmt: skip
rows = [
{**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}},
{**ROW, "번": 4, "이름": "배수관", "확인": None},
]
return _node("helper_m02_hopyo_check.cjs", {"lines": lines, "ledger": ledger, "rows": rows})
def test_확인_창_체크한_줄만_몸():
got = _check()["check"]
assert got["key"] == "m02-hopyo-check:personal::HP-000001:3"
assert got["suggested"] == [ # 서버 체크 제안 그대로 · 배 없는 줄 · % 줄 · 후보는 안 감
{"차례": 1, "대상": [{"로직": "GC000198", "입력값": {"소량감률": 50}, "배": 1}]},
{"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]},
{"차례": 6, "대상": [{"호표": 46, "배": 2.3}]},
]
assert got["edited"] == [ # 캐시 — 맞음 줄 체크 끔 · 뺌 · 고른 대상(몫 · 배)
{"차례": 2, "대상": [{"로직": "GF000101", "입력값": {}, "배": 1}]},
{"차례": 3, "뺌": "우리에 없음"},
{
"차례": 4,
"대상": [{"로직": "GF000211", "입력값": {"유형": "보통"}, "배": 0.1, "몫": 0.1}],
},
{"차례": 6, "대상": [{"호표": 46, "배": 2.3}]},
]
assert got["leftOut"] == {"확인 안 함": 3, "뺌": 1, "% 줄": 1}
assert got["checkable"] == [True, True, True, True, False, False, True, False]
assert got["sure"] == [1, 2] # [맞음 · 별칭 모두 체크]
assert got["compare"] == "콘크리트공 0.1091 → 0.1091 ✓ · 굴착기 0.29 → 0.31 ≠"
assert got["toTarget"] == [{"호표": 46, "배": 2.3}, {"자재": "MT1", "배": 1}]
assert got["newest"] == [False, True, True] # 같은 줄 늦은 응답 버림 · 다른 줄은 따로
assert got["dropped"] is False # 만들기 성공 = 캐시 지움
def test_고르기_창_도우미():
got = _check()["choose"]
assert got["hint"] == ["6-1-1", "고임돌"] # 인용 조항 번호 → 없으면 품명 첫 뜻 낱말
assert got["picked"] == [
{"로직": "GC000206", "입력값": {}, "배": 1, "몫": None},
{"로직": "GF000211", "입력값": {}, "배": 0.1, "몫": 0.1},
]
assert got["inputs"] == "소량감률=50(블록), 지역=서울 · ⚠ 못 가름: 측압"
assert got["word"] == ["만듦", "있던 것을 확인한 내용으로 덮음"]
assert got["qty"] == "콘크리트공 0.1091 · 굴착기 0.29hr"
assert got["target"] == "제46호표 [㎡]"
def test_별칭_장부_화면_합침():
got = _check()["ledger"]
assert got["key"] == "m02-hopyo-ledger:company:7"
assert got["merged"] == [
["나||m3", "personal", True, True, "안 씀"], # 고친 줄
["다||개", "personal", True, True, "짝"], # 위 층 → 내 층으로 고쳐 씀
["다||개", "system", False, False, "짝"], # 위 층 = 그대로 읽기만
] # 지운 줄(가) 없음
assert got["filter"] == ["다||개", "다||개"] and got["kind"] == ["나||m3"]
assert got["pair"] == "GC000198 × 1 · 몫 0.1 · 소량감률=50"
assert got["entry"]["짝"] == [] and "층" not in got["entry"]
def test_길_만들기_통째_만들_층_미리_셈_장부():
calls = _check()["calls"]
build = "/api/m02/layers/company/hopyo-build?owner=7"
head = {"층": "system", "주인": None, "키": "HP-000001", "번": 3}
assert calls[0]["url"] == build and calls[0]["body"]["줄"][0]["차례"] == 1
assert {k: calls[0]["body"][k] for k in head} == head and "새로" not in calls[0]["body"]
assert calls[1]["body"] == {**head, "통째": {"대상": [{"로직": "GF000225", "배": 1}]}}
assert calls[2] == {
"url": "/api/m02/layers/system/hopyo/HP-000001/3?to=company&to_owner=7",
"method": "GET",
"body": None,
}
assert calls[3]["url"] == "/api/m02/layers/personal/hopyo/HP-000001/3/guess"
assert calls[3]["body"]["대상"][0]["몫"] == 0.1
assert calls[4]["url"] == "/api/m02/layers/company/hopyo-ledger?owner=7"
assert calls[5]["method"] == "PUT" and calls[5]["body"] == {"줄": {"가|b|m3": None}}
def test_로직_추가_확인된_것은_바로_참조_아니면_확인_창():
got = _check()["pick"]
assert got["picked"] == [
{
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
"name": "임도표지석",
"alias": "임도표지석",
}
]
assert got["calls"] == [] # 확인 없이 만들지 않음
assert got["opened"] == ["확인 — 북부청.xls 제4호표 배수관"]
def test_엑셀_보기_창_하위_호표_먼저():
assert _check()["sheet"]["order"] == [46, 45, 3, 7, 8] # 순환은 남은 차례
def _pick() -> dict:
row = {**ROW, "확인": {"층": "company", "주인": "7", "키": "SC-000009"}}
return _node("helper_m02_hopyo_pick.cjs", {"rows": [row], "built": {}})
def test_고르기_창_실무_호표_줄():
got = _pick()
assert got["calls"][0] == {
"url": "/api/m02/layers/company/hopyo-picks?owner=7",
"method": "GET",
@@ -69,7 +208,7 @@ def test_고르기_창_실무_호표_줄과_고름():
"tag": "실무 호표",
"name": "임도표지석 910×600×150",
"sub": "북부청.xls · 제3호표 · 마스터",
"unit": "개소 · 맞춤 2/4",
"unit": "개소 · 확인 SC-000009",
},
{
"kind": "hopyo",
@@ -80,119 +219,16 @@ def test_고르기_창_실무_호표_줄과_고름():
"unit": "호표 1",
},
]
build = got["calls"][1]
assert build["url"] == "/api/m02/layers/company/hopyo-build?owner=7" # 만드는 층 = 문서 층
assert build["body"] == {
"층": "system",
"주인": None,
"키": "HP-000001",
"번": 3,
"새로": False,
}
assert got["picked"] == [
{
"ref": {"층": "company", "주인": "7", "키": "SC-000009"},
"name": "임도표지석",
"alias": "임도표지석",
}
]
assert got["picked"][0]["ref"]["키"] == "SC-000009"
assert got["project"] is True # 프로젝트 층 = 덧줄 없음
def test_본_화면_글_도우미():
view = _run()["view"]
def test_글_도우미와_근거_꼬리표():
got = _pick()
view = got["view"]
assert view["target"] == ["구조물터파기 [㎥]", "레미콘 25-21-120 [㎥]", "제46호표 [㎡]"]
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 읽음: 보정작업"
assert view["memory"] == [
{"로직": "GF000101", "입력값": {"지역": "서울"}, "환산": 1},
{"자재": "MT1", "입력값": {}, "환산": 0.5},
None, # 환산 0 = 확정 못 함
None, # 대상 없음
]
assert view["word"] == ["만듦", "다시 씀", "새로 만들어 덮음"] # 알림 · 판 글
def test_줄_표_줄바꿈_없음과_고르기_창_머리():
css = _text("M02_MasterTemplete_Hopyo.css")
rule = css.split(".m02-hopyo__table.m02-hopyo__lines\n :is(th, td):is(")[1].split("}")[0]
cols = [int(n) for n in re.findall(r":nth-child\((\d+)\)", rule)]
assert cols == [1, 4, 5, 6, 7, 9, 10, 11] and "white-space: nowrap;" in rule
heads = ["갈래", "품명", "규격", "수량", "단위", "엑셀 단가", "맞춤", "대상", "입력값", "환산"]
view = _text("M02_MasterTemplete_Hopyo_View.ts")
flat = re.sub(r"\s+", "", view)
assert re.sub(r"\s+", "", ",".join(f'"{h}"' for h in heads)) in flat # 열 차례 그대로
top = css.split(".m02-hopyo__choose .ui-modal__top > .ui-btn {")[1].split("}")[0]
assert "flex-shrink: 0;" in top and "white-space: nowrap;" in top
assert '"가까운 것 없음"' in view and "builtWord(got, fresh)" in view
def test_좌측_판_자리와_권한():
side = _text("M02_MasterTemplete_UI_Side.ts")
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
names = set(re.findall(r"\b\w*[Pp]ractice\w*\b", side))
assert names <= {"buildPracticeSection", "practice", "M02_MasterTemplete_Practice"} # 옛 이름만
assert "LaborSection" not in side and "labor?." not in side and "picked" not in side
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert len(detail.splitlines()) == 693 # 줄 수 그대로(제자리 고침)
assert "withHopyo(library)," in detail
assert 'cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null' in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
for name in NEW:
text = _text(f"M02_MasterTemplete_{name}.ts")
assert len(text.splitlines()) <= 700, name
css = _text("M02_MasterTemplete_Hopyo.css")
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_View.ts")
def test_엑셀_보기_창_행과_호표():
got = _run()
assert got["calls"][2] == {
"url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7",
"method": "GET",
"body": None,
}
sheet = got["sheet"]
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
assert sheet["last"] == ["AA", "A"]
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
view = _text("M02_MasterTemplete_Hopyo_View.ts")
assert lazy in view and '"엑셀로 보기"' in view and '"엑셀에서 보기"' in view
assert "호표: no" in view # 호표 화면 = 그 호표 고른 채
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
assert lazy in pick and "lib.pick(got.참조" in pick
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
for text in (side, view, pick):
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
assert not re.search(
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
)
assert "readOnly: true" in sheet and "wide: true" in sheet
def test_고르기_창_근거_꼬리표와_근거_맞음():
basis = _run()["basis"]
assert view["inputs"] == "횟수=4회사용시, 지역=서울 · ⚠ 못 가름: 보정작업"
basis = got["basis"]
assert basis["ok"] == [True, False, False, False, False] # 조항 같음 · 직종 · 단위 모두
assert basis["plain"] == [True, True] # 줄 직종이 보통인부뿐 · 없음 = 직종 안 봄
assert basis["tag"] == [
@@ -205,8 +241,104 @@ def test_고르기_창_근거_꼬리표와_근거_맞음():
"단가산출 7행",
"",
]
# 지금 대상이 근거 안 맞으면 근거 맞는 후보 · 「후보」 줄만 · 근거 맞는 것 없으면 없음
assert basis["pick"] == ["GC000083", "GC000083", None, None]
def test_엑셀_보기_창_행과_호표():
got = _pick()
assert got["calls"][1] == {
"url": "/api/m02/layers/company/hopyo-grid/HP-000001?owner=7",
"method": "GET",
"body": None,
}
sheet = got["sheet"]
assert sheet["at"] == [None, 3, 3, 3, 4, 4, None] # 22행 → 제3호표 · 범위 밖 = 없음
assert sheet["span"] == "B20:O24" # 제3호표 영역 = B열 ~ 끝 열(열 폭 정보 O)
assert sheet["last"] == ["AA", "A"]
assert sheet["rows"] == ["4345:4345", "93:101"] # 단가산출 행 전체
# ── 소스 ──────────────────────────────────────────────────────────────
def test_옛_즉시_확정과_바로_만들기_없음():
texts = {name: _text(f"M02_MasterTemplete_{name}.ts") for name in NEW}
every = "\n".join(texts.values())
for gone in ("saveHopyoMemory", "hopyo-memory", "HopyoMemory", "memoryOf", "basisPick"):
assert gone not in every, gone
for label in (
"같음 모두 확정",
"근거 맞음 모두 확정",
'"확정"',
'"사전조합 만들기"',
"불러오기",
):
assert label not in every, label
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 펼침 뒤 한 번
check = texts["Hopyo_Check"]
assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
assert "m02-hopyo-check:" in check and "POLL_MS" in check
sheet = texts["Hopyo_Sheet"]
assert "확인하고 만들기" in sheet and "이대로 ${ready.length}개 만들기" in sheet
assert "확인 창 열기" in sheet and "buildOrder(" in sheet
pick = texts["Hopyo_Pick"]
assert "if (row.확인)" in pick and 'import("./M02_MasterTemplete_Hopyo_Check")' in pick
view = texts["Hopyo_View"]
assert "buildHopyoCheck(" in view and "choose(" not in view
ledger = texts["Hopyo_Ledger"]
assert "m02-hopyo-ledger:" in ledger and "saveLedger(" in ledger
for gone in ("발주처", "회사로 올리기", "쓰인 횟수"):
assert gone not in ledger.split("*/", 1)[1] # 머리 글 「안 둠」 밖
def test_좌측_판_자리와_권한():
side = _text("M02_MasterTemplete_UI_Side.ts")
assert 'import { buildHopyoSection } from "./M02_MasterTemplete_Hopyo_Side";' in side
host = side.split('className: "m02-side__lists ui-sidebar-panel"')[1]
assert host.index("precomboSec,") < host.index("section(hopyo.title, hopyo.body),")
assert host.index("section(hopyo.title, hopyo.body),") < host.index("extras ?")
assert "canWrite: canEdit(layer, opt.isAdmin)," in side # 구조물 조합 층 쓰기 권한
assert "hopyo.paint();" in side.split("function paint(): void {")[1]
hopyo_side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert 'accept: ".xls,.xlsx"' in hopyo_side and "input.hidden = true;" in hopyo_side
assert "disabled: !opt.canWrite," in hopyo_side and "pageScope()" in hopyo_side
assert "buildHopyoLedger({ scope, canWrite: opt.canWrite })" in hopyo_side # [별칭 장부]
assert hopyo_side.index("list, ledger]") > 0
def test_로직_추가_한_곳과_파일_규칙():
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
assert len(detail.splitlines()) == 693 # 줄 수 그대로
assert "withHopyo(library)," in detail
pick = _text("M02_MasterTemplete_Labor_Pick.ts")
assert "more?: () => Promise<PickExtra[]>;" in pick and "...later" in pick
assert "if (pre?.query) finder.search.value = pre.query;" in pick # 찾기 글 미리 넣음
for name in NEW:
text = _text(f"M02_MasterTemplete_{name}.ts")
assert len(text.splitlines()) <= 700, name
css = _text("M02_MasterTemplete_Hopyo.css")
assert "var(--color-" in css and not re.search(r"#[0-9a-fA-F]{3,6}\b", css)
for state in ("맞음", "별칭", "다른점", "후보"):
assert f".m02-hopyo__state.is-{state}" in css
assert '"./M02_MasterTemplete_Hopyo.css"' in _text("M02_MasterTemplete_Hopyo_Check.ts")
def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
lazy = 'import("./M02_MasterTemplete_Hopyo_Sheet")'
side = _text("M02_MasterTemplete_Hopyo_Side.ts")
assert lazy in side.split("await show(got);")[1].split("})")[0] # 올린 직후 자동
view = _text("M02_MasterTemplete_Hopyo_View.ts")
assert "단산행: basis.행" in view and "근거 맞음 모두 확정" in view
assert "x.근거 || x.맞춤.후보?.some((c) => c.근거)" in view # 옛 파일 = 단추 없음
assert lazy in view and '"엑셀로 보기"' in view
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check
assert "단산행: 행" in check
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
assert lazy in pick
static = r'^import (?!type )[^(;]*"\./M02_MasterTemplete_Hopyo_Sheet"'
for text in (side, view, pick, check):
assert not re.search(static, text, re.M) # 정적 import 없음(타입만)
sheet = _text("M02_MasterTemplete_Hopyo_Sheet.ts")
assert 'import("../A00_Common/spreadsheet/spreadsheet")' in sheet
assert not re.search(
r'^import \{[^}]*\} from "\.\./A00_Common/spreadsheet/spreadsheet"', sheet, re.M
)
assert "readOnly: true" in sheet and "wide: true" in sheet
+4 -6
View File
@@ -2,8 +2,8 @@
화면 도우미(`helper_m02_hopyo_seam.cjs` → Hopyo_Sheet · Hopyo_View)에 먹임.
행 22 → 제3호표 · 25 빈 줄 · 26 → 제4호표 · 영역 B20:O24 · 단산93 행 4229:4244 ·
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123 ·
앞 마디 같음 0(330 덧고침 2).
엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 · 북부청 「후보」 줄 1순위 근거 맞음 = 서버 기준 123
(338 — 근거 맞음 모두 확정 단추 없앰 · 도우미는 고르기 창 `Hopyo_Choose`).
"""
import json
@@ -42,14 +42,12 @@ def test_실제_응답_화면_도우미(client):
res = client.get(f"{URL.format('personal')}/HP-000001/{one['번']}").json()
every += [x for x in res["호표"]["줄"] if x["갈래"] == "단가산출"]
got = _node({"grid": grid, "lines": lines, "all": every})
assert got["count"]["top"] == 123 and got["count"]["half"] == 0
assert got["count"]["pick"] >= 123
assert got["count"]["top"] == 123
assert got["at"] == [None, 3, 3, 3, None, 4] # 25행 = 호표 사이 빈 줄
assert got["span"] == "B20:O24" and got["rows"] == "4229:4244"
by = {x["이름"]: x for x in got["lines"]}
dig = by["구조물터파기(인력100%)"]
assert dig["source"] == "출처 건설품셈 3-2-1 · 직종 특별인부 · 단가산출 4229–4244행"
assert dig["tag"] == "조항 건설품셈 공통 3-2-1 · 직종 특별인부 · 단위 같음"
assert dig["ok"] is True and dig["pick"] == "GC000083"
assert by["비탈면 보호공"]["pick"] == "GC000143"
assert dig["ok"] is True
assert by["흙깍기(토사)"]["ok"] is True # 엑셀 직종 없는 줄(기계) = 조항 같음 + 단위
+217
View File
@@ -0,0 +1,217 @@
"""일위대가 실무자료 STmate 꼴 읽기(PLAN 338-2b · `M02_Hopyo_Stmate`).
여섯 파일 올라감(봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표) ·
봉화 줄 216(구성 209 + 근거글 7) · 단산 103 · 노임 30 · 호표 22 · 줄 코드 갈래 ·
노임 이름 어긋남 0 · 블록 자원 품 · 이음 열쇠(M~Z 열 코드 꼴 첫 것) ·
문서 보안(장수 · 진안) 400 글 · 원본 엑셀 · master_data 그대로 ·
영월 정답 자리(`y_ids` — 338 실험 C1 `labels_y` Y000~Y035 = D · X 고유 줄 차례 · 골든 시험이 씀).
"""
import hashlib
from pathlib import Path
import pytest
from M02_MasterTemplete import M02_Hopyo_Basis as hb
from M02_MasterTemplete import M02_Hopyo_Excel as hx
from M02_MasterTemplete import M02_Hopyo_Stmate as st
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester import test_m02_hopyo_excel as base
from resources.tester.test_m02_hopyo_excel import ROOT, _post, master_hash
client = base.client # 공용 고정물(임시 층)
DOCS = ROOT / "resources" / "knowledge" / "original" / "실무문서"
FILES = {
"봉화": "04.산불 봉화/01. 내역서/2024년 산불진화임도 실시설계(기번41_현동)_ L=3.46km.xlsx",
"영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
"대흥설계": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/"
"2024년 간선임도사업(기번3).xlsx",
"대흥착공": "2024년 간선임도(기번3-울진.대흥)(공통)/"
"2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx",
"영덕": "20250527 2025년 계류보전사업(기번1-영덕.병곡.영리.산214-1)(변경)/01.내역자료/"
"2025년 계류보전사업(기번1 영덕.병곡.영.산214)(변경).xlsx",
"소광": "25년 산불진화임도(기번8)(울진소광)/"
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
}
HOPYOS = {"봉화": 45, "영월": 23, "대흥설계": 34, "대흥착공": 34, "영덕": 9, "소광": 25}
LOCKED = {
"장수": "02.산불 장수/01.내역서/"
"00.단가변경_26년_산불_무주(장수,장수,덕산,산54-1)내역+수량.xlsx",
"진안": "03.산불진안/01.내역서/00.단가변경_26년_산불_무주(진안,백운,백암,산1-2)내역+수량.xlsx",
}
def _path(tag: str) -> Path:
return DOCS / {**FILES, **LOCKED}[tag]
@pytest.fixture(scope="module")
def books() -> dict[str, dict]:
return {tag: hx.read_book(_path(tag).name, _path(tag).read_bytes()) for tag in FILES}
def _lines(book: dict) -> list[dict]:
return [x for h in book["호표"] for x in h["줄"]]
def test_여섯_파일_호표_수(books):
assert {tag: len(b["호표"]) for tag, b in books.items()} == HOPYOS
for book in books.values():
assert (book["꼴"], book["시트"]) == ("STmate", "일위대가표")
assert book["검산"] == {"어긋남": [], "총괄표": 0}
assert len(_lines(books["대흥설계"])) == len(_lines(books["대흥착공"])) == 171 # 같은 설계
def test_봉화_줄_갈래(books):
bong = books["봉화"]
lines = _lines(bong)
kinds = {k: sum(1 for x in lines if x["갈래"] == k) for k in ("단가산출", "노임", "호표", "%")}
assert kinds == {"단가산출": 103, "노임": 30, "호표": 22, "%": 7}
assert len(lines) + sum(len(h.get("근거글") or []) for h in bong["호표"]) == 216
assert all(x["코드"] for x in lines)
for book in books.values():
for x in _lines(book):
if x["갈래"] == "호표":
assert x["참조"]["번"] in {h["번"] for h in book["호표"]}
if x["갈래"] == "단가산출":
assert x["참조"] == {"종류": "단산", "번": int(x["코드"][1:]), "코드": x["코드"]}
assert "근거" in x # 블록 모두 이어짐
by = {h["번"]: h for h in bong["호표"]}
assert by[13]["근거글"] == ["건설표준품셈 7-2-2(찰붙임)"] # 코드 없는 줄 = 호표 근거글
assert hb.sources(" ".join(by[13]["근거글"])) == [["건설품셈", "7-2-2"]]
def test_영월_줄과_블록(books):
yw = books["영월"]
one = yw["호표"][0]
assert (one["번"], one["코드"], one["이름"], one["단위"]) == (
1,
"B00260",
"녹생토(종자배합)",
"M3",
)
assert one["행"] == [5, 10] and one["금액"]["계"] == 155184
assert [(x["이름"], x["코드"], x["갈래"], x["수량"]) for x in one["줄"]] == [
("산림종자", "M00024", "자재", 0.25),
("산림종자", "M00211", "자재", 0.25),
("인공토양(녹생토)", "M00832", "자재", 1.1),
("보통인부", "L00002", "노임", 0.43),
]
two = {x["이름"]: x for x in yw["호표"][1]["줄"]}
assert two["국가지점번호판 검증수수료"]["갈래"] == "별도계상" # 비고 「별산」
assert two["구조물터파기(인력)"]["근거"] == {
"출처": [["건설품셈", "3-1-3"]],
"직종": ["보통인부"],
"행": [5, 54],
}
block = next(b for b in yw["단산"] if b["코드"] == "D00002")
assert (block["번"], block["이름"], block["규격"], block["단위"]) == (
2,
"구조물터파기(인력)",
"경질토사",
"M3",
)
assert block["자원"] == [
{"갈래": "L", "코드": "L00002", "이름": "보통인부", "규격": "", "품": 0.26, "절": ""}
]
assert any("건설표준품셈3-1-3" in t for t in block["글"])
def test_기계_자원_하나로(books):
block = next(b for b in books["봉화"]["단산"] if b["코드"] == "D00002")
machines = [r for r in block["자원"] if r["갈래"] == "X"]
assert len(machines) == 1 # 노무비 · 재료비 · 경비 셋 줄
assert machines[0]["이름"] == "굴삭기(무한궤도)" and machines[0]["규격"] == "0.2㎥"
assert machines[0]["품"] == pytest.approx(0.0636537237529)
def test_노임_이름_어긋남_0(books):
names = hb.trade_names()
for tag in FILES:
sheets = hx._sheets(_path(tag).name, _path(tag).read_bytes())
table = st._lists(sheets)
res = [r for b in books[tag]["단산"] for r in b["자원"] if r["갈래"] == "L" and r["이름"]]
assert res
assert sum(1 for r in res if r["코드"] in table) >= len(res) // 2
for r in res:
if r["코드"] in table: # 목록표에 있는 직종 — X 열 이름과 같음
assert st._flat(r["이름"]) == st._flat(table[r["코드"]]["이름"])
if table.get(r["코드"], {}).get("단위") == "인": # L 코드 % 줄(기구손료) 뺌
assert st._flat(r["이름"]) in names, (tag, r)
labor = [x for x in _lines(books[tag]) if x["갈래"] == "노임"]
assert all(st._flat(x["이름"]) in names for x in labor)
def test_이음_열쇠_코드_칸():
row = [None] * 26
row[12], row[14], row[15] = "별산M00985", "M00985_1", "T7_1"
assert st._code(row) == "M00985"
row = [None] * 26
row[25] = "D01352 →" # O 열 없는 판
assert st._code(row) == "D01352"
row = [None] * 47
row[14], row[13] = "PRA01_1", "_x0007_`COD|PR_x0005_`EXI"
assert st._code(row) == "PRA01"
assert (
st._code(["계", None, None, None, None, None, None, None] + [None] * 6 + ["T7_1"]) == "T7"
)
assert st._code([None] * 12 + ["D96"]) is None # 비고 글은 코드 아님
def test_문서_보안_400(client):
for tag in LOCKED:
data = _path(tag).read_bytes()
with pytest.raises(hx.ExcelError, match="문서 보안"):
hx.read_book(_path(tag).name, data)
res = _post(client, data=data, name=_path(tag).name)
assert res.status_code == 400 and res.json()["detail"] == hx.DRM
def test_여섯_파일_올라감(client):
before = master_hash()
sources = {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES}
for at, tag in enumerate(FILES, 1):
res = _post(client, data=_path(tag).read_bytes(), name=_path(tag).name)
assert res.status_code == 200, res.text
assert res.json()["호표수"] == HOPYOS[tag]
doc = hs.read(layers.personal_dir(7, 42), f"HP-{at:06d}")
assert doc["꼴"] == "STmate"
assert sum(1 for b in doc["단산"] if not b["글"]) <= 3 # 봉화 무대처리 · 흙 쌓 기 = 값 0
assert any(b["자원"] for b in doc["단산"])
first = doc["호표"][0]["번"]
view = client.get(f"{base.URL.format('personal')}/HP-{at:06d}/{first}")
assert view.status_code == 200, view.text # 맞춤 보기가 STmate 줄을 받음
assert {tag: hashlib.sha256(_path(tag).read_bytes()).hexdigest() for tag in FILES} == sources
assert master_hash() == before
def y_ids(book: dict) -> dict[str, dict]:
"""영월 정답 자리 — D · X 줄을 (이름, 규격, 단위, 코드) 고유로 처음 나온 차례 →
`{"Y000": {호표, 차례, 열쇠}}`(338 실험 C1 `s09_stmate` 와 같은 번)."""
out: dict[str, dict] = {}
seen: set[tuple] = set()
for hopyo in book["호표"]:
for line in hopyo["줄"]:
key = (line["이름"], line["규격"], line["단위"], line["코드"])
if line["코드"][0] in "DX" and key not in seen:
seen.add(key)
out[f"Y{len(out):03d}"] = {"호표": hopyo["번"], "차례": line["차례"], "열쇠": key}
return out
def test_영월_정답_자리(books):
got = y_ids(books["영월"])
assert len(got) == 36 # 정답 34 + 우리에 없음 둘(Y004 · Y007)
assert got["Y000"] == {
"호표": 2,
"차례": 1,
"열쇠": ("무근콘크리트타설", "인력운반타설", "M3", "D02058"),
}
assert got["Y005"]["열쇠"] == ("크레인(타이어)", "10톤", "시간", "X02104")
assert got["Y010"]["열쇠"][3] == "D00060" and got["Y035"]["열쇠"][3] == "D01810"
by = {h["번"]: h for h in books["영월"]["호표"]}
for one in got.values():
line = by[one["호표"]]["줄"][one["차례"]]
assert (line["이름"], line["규격"], line["단위"], line["코드"]) == one["열쇠"]
assert line["갈래"] == "기계" or "근거" in line # 단산 줄은 블록 이음