feat(B09): 대비표 출처 열 없앰 · 출처 뜻 단가 칸 호버 · 견적서 글자 수기 견적 줄만(185-4 · 185-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -9,7 +9,8 @@
|
||||
* `pick` · 옛 수기 `applied` 지움) · 고른 출처 단가 칸 강조(`is-picked` · 기본 = 최저 출처) ·
|
||||
* 관급 / 별도 칸 누름 = 표시 바꿈 · 견적 단가 손입력
|
||||
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
|
||||
* · 출처 묶음 둘째 칸 = 「출처」(M01 면수 · 유가 기준일 값 그대로 · 183-2 · 견적엔 없음)
|
||||
* 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶)
|
||||
* · 출처 묶음 = 단가 칸 하나 · 출처 뜻(M01 면수 · 유가 기준일)은 단가 칸 호버(185-4 `sourceInfo`)
|
||||
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -125,10 +126,33 @@ function appliedChoices(r: Row, pick: (s: string | null) => void): MapContextMen
|
||||
];
|
||||
}
|
||||
|
||||
/** 출처 단가 칸 호버 글(185-4) — M01 면수 「670(26.7)」 · 유가 기준일 「2026-08-14」 를 뜻으로 풂 ·
|
||||
* 값 없는(0 · 빈칸) 칸 = 없음 · 값 · 저장 꼴은 그대로 */
|
||||
export function sourceInfo(r: Row, s: string, files = 0): string | null {
|
||||
if (!(Number(r.prices?.[s]) > 0)) return null;
|
||||
const page = String(r.pages?.[s] ?? "").trim();
|
||||
if (s === "관급") return "관급 · 조달청 계약 단가";
|
||||
if (s === "견적") return `견적 · 수기 입력${files ? ` · 견적서 ${files} 파일` : ""}`;
|
||||
if (s === "유가") {
|
||||
const from = String(r.source ?? "");
|
||||
if (!page) return "유가";
|
||||
if (from.includes("오피넷")) return `유가 · 오피넷 · ${page} 수집`;
|
||||
if (from.includes("한전")) return `전기요금 · 한전 · ${page} 시행`;
|
||||
return `유가 · ${page} 기준`;
|
||||
}
|
||||
// 면수 = [부(부록) · 하(하권) · n권] 쪽(연.월) — 예 「부536(26.7)」 = 2026-07 호 부록 536쪽
|
||||
const m = /^(부|하|\d+권)?(\d+)\((\d{2})\.(\d{1,2})\)$/.exec(page);
|
||||
if (!m) return page ? `${s} · ${page}` : `${s} · 면수 없음`;
|
||||
const book = m[1] === "부" ? "부록 " : m[1] === "하" ? "하권 " : m[1] ? `${m[1]} ` : "";
|
||||
return `${s} · 20${m[3]}-${m[4].padStart(2, "0")} · ${book}${m[2]}쪽`;
|
||||
}
|
||||
|
||||
export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
const rows: Row[] = sheets.compare || [];
|
||||
const out: DataRow[] = rows.map((r) => {
|
||||
const files: string[] = (user.materials || {})[r.key]?.files || [];
|
||||
// 185-5 견적서 글자 = 견적 단가 수기 줄만(올린 파일이 남은 줄도 · 나머지 빈칸)
|
||||
const quoted = r.prices["견적"] != null || files.length > 0;
|
||||
const row: DataRow = {
|
||||
no: r.no,
|
||||
key: r.key,
|
||||
@@ -139,12 +163,9 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
applied: r.applied,
|
||||
applied_from: r.pick ? `${r.pick} 고름` : (r.applied_from ?? ""),
|
||||
supply: r.supply ?? "",
|
||||
files: files.length ? `${files.length} 파일` : "첨부",
|
||||
files: !quoted ? "" : files.length ? `${files.length} 파일` : "첨부",
|
||||
};
|
||||
for (const s of SOURCES) {
|
||||
row[`p:${s}`] = r.prices[s];
|
||||
row[`g:${s}`] = r.pages[s] ?? "";
|
||||
}
|
||||
for (const s of SOURCES) row[`p:${s}`] = r.prices[s];
|
||||
return row;
|
||||
});
|
||||
const mine = (key: string): Row => ({ ...((user.materials || {})[key] || {}) });
|
||||
@@ -160,11 +181,12 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
text("name", "품명", 180),
|
||||
text("spec", "규격", 140),
|
||||
text("unit", "단위", 56),
|
||||
// 183-2 — 견적 = 단가 · 견적서 · 그 밖 = 단가 · 출처(면수 값)
|
||||
...SOURCES.flatMap((s) => [
|
||||
money(`p:${s}`, "단가", [s]),
|
||||
s === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s]),
|
||||
]),
|
||||
// 185-4 — 출처 = 단가 하나(출처 뜻은 단가 칸 호버) · 견적 = 단가 · 견적서
|
||||
...SOURCES.flatMap((s) =>
|
||||
s === "견적"
|
||||
? [money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])]
|
||||
: [money(`p:${s}`, "단가", [s])],
|
||||
),
|
||||
money("lowest", "최저"),
|
||||
money("applied", "적용 단가", ["적용"]),
|
||||
text("applied_from", "출처", 80, ["적용"]),
|
||||
@@ -172,7 +194,15 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
],
|
||||
rows: out,
|
||||
frozen: 2,
|
||||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기",
|
||||
hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 「적용 단가」 칸 누름 = 고르기(값 있는 출처 · 견적 · 최저(자동) · 견적이 높아도 고름) · 고른 출처 단가 칸 강조 · 견적 단가 = 수기 입력(출처 없는 B08 줄은 견적으로 단가) · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸(견적 단가를 넣은 줄만) 누름 = 파일 올리기 · 보기 · 출처 단가 칸에 마우스 = 출처 뜻(책 · 호 · 쪽 · 기준일)",
|
||||
tip: (i, key) =>
|
||||
key.startsWith("p:") && rows[i]
|
||||
? sourceInfo(
|
||||
rows[i],
|
||||
key.slice(2),
|
||||
((user.materials || {})[rows[i].key]?.files || []).length,
|
||||
)
|
||||
: null,
|
||||
editable: (_i, key) => key === "p:견적",
|
||||
onEdit: (i, key, value) => {
|
||||
const row = out[i];
|
||||
@@ -201,7 +231,7 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => {
|
||||
const r = rows[i];
|
||||
if (!row || !r) return;
|
||||
const cell = mine(String(row.key));
|
||||
if (key === "files") {
|
||||
if (key === "files" && row.files) {
|
||||
const label = `${r.name} ${r.spec}`.trim();
|
||||
return openQuotes(r.key, label, (names) => {
|
||||
const now = mine(r.key);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 다른 출처 「면」 → 「출처」(값 그대로) · 견적서 = 견적 아래.
|
||||
"""183-2 자재단가대비표 머리 — 견적 「면」 뺌 · 견적서 = 견적 아래 · 다른 출처 「출처」 열은 185-4 에서 호버로.
|
||||
|
||||
소스 시험(열 정의 · 고칠 칸) — 셈 · 저장(`quote_page` 남음)은 안 바뀜(test_166_4).
|
||||
"""
|
||||
@@ -14,11 +14,12 @@ def _compare() -> str:
|
||||
return SRC[start : SRC.index("export const buildMaterial")]
|
||||
|
||||
|
||||
def test_견적은_단가_견적서_그_밖은_단가_출처():
|
||||
def test_견적은_단가_견적서_그_밖은_단가_하나():
|
||||
# 185-4 — 출처 열 없앰(출처 뜻 = 단가 칸 호버 · test_185_4)
|
||||
body = _compare()
|
||||
assert (
|
||||
's === "견적" ? text("files", "견적서", 72, [s]) : text(`g:${s}`, "출처", 64, [s])' in body
|
||||
)
|
||||
assert '[money(`p:${s}`, "단가", [s]), text("files", "견적서", 72, [s])]' in body
|
||||
assert ': [money(`p:${s}`, "단가", [s])],' in body
|
||||
assert "g:${s}" not in body and '"출처", 64' not in body
|
||||
assert '"면"' not in body # 면 머리 없음
|
||||
assert body.count('text("files", "견적서"') == 1 # 맨 끝 견적서 열 지움
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""185-4 · 185-5 자재단가대비표 — 「출처」 열 없앰 · 출처 뜻 = 단가 칸 호버 · 견적서 글자 = 수기 견적 줄만.
|
||||
|
||||
Node 로 `buildCompare` 를 그대로 돌림(이웃 모듈은 작은 대역) · 값 · 저장 꼴(`user.materials`)은 그대로.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS = ROOT / "config" / "node_modules" / "typescript"
|
||||
BASE = ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Base.ts"
|
||||
|
||||
PRELUDE = f"""
|
||||
const fs = require("fs");
|
||||
const ts = require({json.dumps(str(TS))});
|
||||
const js = ts.transpileModule(fs.readFileSync({json.dumps(str(BASE))}, "utf8"), {{
|
||||
compilerOptions: {{ module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }},
|
||||
}}).outputText;
|
||||
const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적"];
|
||||
const col = (kind) => (key, label, a, b) =>
|
||||
({{ key, label, kind, group: Array.isArray(a) ? a : Array.isArray(b) ? b : undefined }});
|
||||
const opened = [];
|
||||
const stubs = {{
|
||||
"./B09_Estimation_Calc_Base": {{ SOURCES }},
|
||||
"./B09_Estimation_UI_Tabs_Base_Quote": {{ openQuotes: (key) => opened.push(key) }},
|
||||
"./B09_Estimation_UI_Tabs": {{
|
||||
money: col("number"), qty: col("number"), text: col(undefined),
|
||||
parseNumber: (t) => (t === "" ? null : Number(t)),
|
||||
}},
|
||||
}};
|
||||
const mod = {{ exports: {{}} }};
|
||||
new Function("exports", "module", "require", js)(mod.exports, mod, (p) => stubs[p]);
|
||||
const B = mod.exports;
|
||||
const out = {{}};
|
||||
"""
|
||||
|
||||
|
||||
def _node(body: str):
|
||||
script = PRELUDE + body + "\nconsole.log(JSON.stringify(out));"
|
||||
got = subprocess.run(
|
||||
["node", "-e", script], capture_output=True, text=True, encoding="utf-8", timeout=60
|
||||
)
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
ROWS = """
|
||||
const blank = Object.fromEntries(SOURCES.map((s) => [s, null]));
|
||||
const compare = [
|
||||
{ key: "MT1", name: "백호우티스", spec: "0.2㎡", unit: "개", applied_from: "유통물가",
|
||||
prices: { ...blank, 유통물가: 5880, 물가정보: 9200, 관급: 5000 },
|
||||
pages: { ...blank, 유통물가: "1094(26.7)", 물가정보: "부536(26.7)" } },
|
||||
{ key: "MO2", name: "자동차용경유", spec: "전국평균", unit: "L", applied_from: "유가",
|
||||
source: "유가전력 오피넷유가 D047", prices: { ...blank, 유가: 1846.39 }, pages: { ...blank, 유가: "2026-08-14" } },
|
||||
{ key: "B08:1", name: "견적 자재", spec: "", unit: "개", applied_from: "견적",
|
||||
prices: { ...blank, 견적: 12000 }, pages: { ...blank } },
|
||||
{ key: "B08:2", name: "파일만 남은 줄", spec: "", unit: "개", applied_from: null,
|
||||
prices: { ...blank }, pages: { ...blank } },
|
||||
];
|
||||
const user = { materials: { "B08:1": { quote: 12000, files: ["a.pdf", "b.pdf"] }, "B08:2": { files: ["c.pdf"] } } };
|
||||
let saved = null;
|
||||
const table = B.buildCompare({ sheets: { compare }, user, setUser: (u) => (saved = u) });
|
||||
"""
|
||||
|
||||
|
||||
def test_출처_열_없음_출처_묶음은_단가_하나_견적만_견적서():
|
||||
got = _node(
|
||||
ROWS
|
||||
+ """
|
||||
out.keys = table.columns.map((c) => c.key);
|
||||
out.labels = table.columns.filter((c) => c.group).map((c) => `${c.group[0]}/${c.label}`);
|
||||
out.rowKeys = Object.keys(table.rows[0]);
|
||||
"""
|
||||
)
|
||||
assert not [k for k in got["keys"] if k.startswith("g:")]
|
||||
assert got["labels"][:7] == [
|
||||
"관급/단가",
|
||||
"물가정보/단가",
|
||||
"물가자료/단가",
|
||||
"유통물가/단가",
|
||||
"거래가격/단가",
|
||||
"유가/단가",
|
||||
"견적/단가",
|
||||
]
|
||||
assert got["labels"][7] == "견적/견적서" and got["labels"].count("견적/견적서") == 1
|
||||
assert not [k for k in got["rowKeys"] if k.startswith("g:")]
|
||||
assert "applied_from" in got["keys"] # 적용 묶음의 「출처」(고른 출처 이름)는 그대로
|
||||
|
||||
|
||||
def test_출처_단가_칸_호버_글_숫자_뜻을_풀어_짧게():
|
||||
got = _node(
|
||||
ROWS
|
||||
+ """
|
||||
const tip = (i, k) => table.tip(i, k);
|
||||
out.tips = [tip(0, "p:유통물가"), tip(0, "p:물가정보"), tip(0, "p:관급"), tip(0, "p:거래가격"),
|
||||
tip(1, "p:유가"), tip(2, "p:견적"), tip(0, "name"), tip(0, "applied")];
|
||||
const r = (s, page) => ({ prices: { [s]: 100 }, pages: { [s]: page } });
|
||||
out.pages = [
|
||||
B.sourceInfo(r("물가자료", "하38(26.7)"), "물가자료"),
|
||||
B.sourceInfo(r("거래가격", "1권413(17.4)"), "거래가격"),
|
||||
B.sourceInfo(r("물가정보", "102(25.12)"), "물가정보"),
|
||||
B.sourceInfo(r("물가정보", "536(26적정)"), "물가정보"),
|
||||
B.sourceInfo(r("물가정보", null), "물가정보"),
|
||||
B.sourceInfo({ prices: { 물가정보: 0 }, pages: { 물가정보: "1(26.7)" } }, "물가정보"),
|
||||
B.sourceInfo({ ...r("유가", "2025-01-01"), source: "유가전력 한전 전기요금표" }, "유가"),
|
||||
B.sourceInfo(r("견적", null), "견적"),
|
||||
];
|
||||
"""
|
||||
)
|
||||
assert got["tips"] == [
|
||||
"유통물가 · 2026-07 · 1094쪽",
|
||||
"물가정보 · 2026-07 · 부록 536쪽",
|
||||
"관급 · 조달청 계약 단가",
|
||||
None, # 값 없는 칸 = 안 뜸
|
||||
"유가 · 오피넷 · 2026-08-14 수집",
|
||||
"견적 · 수기 입력 · 견적서 2 파일",
|
||||
None,
|
||||
None,
|
||||
]
|
||||
assert got["pages"] == [
|
||||
"물가자료 · 2026-07 · 하권 38쪽",
|
||||
"거래가격 · 2017-04 · 1권 413쪽",
|
||||
"물가정보 · 2025-12 · 102쪽",
|
||||
"물가정보 · 536(26적정)", # 못 푸는 꼴 = 원문 그대로
|
||||
"물가정보 · 면수 없음",
|
||||
None,
|
||||
"전기요금 · 한전 · 2025-01-01 시행",
|
||||
"견적 · 수기 입력",
|
||||
]
|
||||
|
||||
|
||||
def test_견적서_글자는_수기_견적_줄만_빈칸은_안_엶_값_저장_꼴_그대로():
|
||||
got = _node(
|
||||
ROWS
|
||||
+ """
|
||||
out.files = table.rows.map((r) => r.files);
|
||||
out.prices = table.rows.map((r) => r["p:물가정보"]);
|
||||
table.onCell(0, "files");
|
||||
table.onCell(1, "files");
|
||||
table.onCell(2, "files");
|
||||
table.onCell(3, "files");
|
||||
out.opened = opened;
|
||||
table.onEdit(1, "p:견적", "3000");
|
||||
out.saved = saved.materials;
|
||||
"""
|
||||
)
|
||||
assert got["files"] == ["", "", "2 파일", "1 파일"] # 파일이 남은 줄은 안 숨김
|
||||
assert got["prices"] == [9200, None, None, None]
|
||||
assert got["opened"] == ["B08:1", "B08:2"]
|
||||
assert got["saved"]["MO2"] == {"quote": 3000}
|
||||
assert got["saved"]["B08:1"] == {"quote": 12000, "files": ["a.pdf", "b.pdf"]}
|
||||
|
||||
|
||||
def test_Shell_칸_풍선은_공용_호버_정보():
|
||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text(encoding="utf-8")
|
||||
assert 'import { attachHoverInfo } from "@ui/ui_template_hover_info";' in shell
|
||||
assert '"tr[data-i] td",' in shell and "cell.title = tip" not in shell
|
||||
Reference in New Issue
Block a user