Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-11-01 23:16:12 +09:00
11 changed files with 530 additions and 779 deletions
+56 -13
View File
@@ -1,7 +1,9 @@
"""717-2b M02 단가산출 상세 · 고침 지킴 시험 — 순수 꼴(node) · 글 · 서버 저장 왕복(임시 storage).
- 고친 칸 → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) · 더한줄 수량 · 단가 · 뺀줄 · 되살림
- 로직 줄 단가 = 노 · 재 · 경 몫 비로 나눔 · 계산서 묶음 = 표 줄 그대로(별도계상 재료 = 금액 없음)
- 공용 상세 고침(724-1 `setLineCover`) → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) ·
더한줄 수량 · 단가 · 뺀줄 · 되살림
- SK 셈 답 → 공용 상세 줄(`hoView` — 정체 차례 · 바탕 줄 그대로 · 더한줄 · 고침 열쇠) ·
계산서 묶음 = 표 줄 그대로
- 화면 문서 꼴이 서버 저장(`clean_doc`)을 지나고 서버가 원값 · 바탕판을 다시 적음 · 셈 = 손셈
- 상세 = M01 상세 부품(M02 길) · 부품 상자만 · 마스터 이동 단추 = 등록 없으면 숨김
"""
@@ -44,7 +46,7 @@ SAMPLE = """
const line = (cid, 이름, 수량, 단가, 비목, 묶음) => ({ cid, 이름, 단위: "인", 수량, 단가,
금액: Object.values(비목).reduce((a, b) => a + b, 0), 비목, 묶음 });
const bare = { 기준: null, 짝잃음: [], 바탕판: "x",
정체: ["labor:A#0", "logic:G#0", "material:M#0"],
정체: ["labor:A#0", "logic:G#0", "material:M#0", "pct:공구#0"],
줄: [line("labor:A#0", "석공", 0.1, 1000, { 노무비: 100 }, "인력"),
line("logic:G#0", "굴착기", 2, 50, { 노무비: 60, 재료비: 30, 경비: 10 }, "경비")],
재료: [{ 차례: 2, 이름: "모르타르", 수량: 0.5, 단가: 200, 금액: 100 }] };
@@ -56,23 +58,25 @@ def test_고침_꼴():
body = (
SAMPLE
+ """
const doc = { 덮개: {}, 더한줄: [{ 종류: "재료", 요소: "MT1", 수량: 1 }], 뺀줄: [] };
const doc = { 덮개: {}, 뺀줄: [],
더한줄: [{ 종류: "재료", 요소: "MT1", 비목: "재료비", 수량: 1 }] };
const ans = { ...bare, 정체: [...bare.정체, "add:#0"],
줄: [...bare.줄, line("add:#0", "더한 재료", 1, 300, { 재료비: 300 }, "재료")] };
const rows = rowsOf(ans, bare, doc);
const by = (cid) => rowsOf(ans, bare, doc).find((r) => r.cid === cid);
const out = {};
out.rows = rows.map((r) => [r.cid, r.묶음, r.apart, r.added, r.priceEdit]);
setQty(doc, by("labor:A#0"), 0.2, false);
setLineCover(doc, by("labor:A#0"), { qty: 0.2 }, false);
out.qty = doc.덮개["labor:A#0"];
setQty(doc, by("labor:A#0"), 0.1, false); // 바탕 값과 같음 = 덮개 지움
setLineCover(doc, by("labor:A#0"), { qty: 0.1 }, false); // 바탕 값과 같음 = 덮개 지움
out.qtyBack = doc.덮개["labor:A#0"] ?? null;
setPrice(doc, by("labor:A#0"), 1200);
setLineCover(doc, by("labor:A#0"), { labor: 1200 }, false);
out.price = doc.덮개["labor:A#0"];
setPrice(doc, by("logic:G#0"), 100); // 노 60 · 재 30 · 경 10 비
setLineCover(doc, by("labor:A#0"), null, false); // 고침 풂
out.unset = doc.덮개["labor:A#0"] ?? null;
setLineCover(doc, by("logic:G#0"), { labor: 60, material: 30, expense: 10 }, false);
out.split = doc.덮개["logic:G#0"];
setQty(doc, by("add:#0"), 3, false);
setPrice(doc, by("add:#0"), 350);
setLineCover(doc, by("add:#0"), { qty: 3, material: 350 }, false);
out.added = doc.더한줄[0];
out.pick = [by("add:#0").apartPick, by("labor:A#0").apartPick];
setApart(doc, by("add:#0"), true);
@@ -80,9 +84,9 @@ out.apartOn = doc.더한줄[0].별도계상 ?? null;
setApart(doc, by("add:#0"), false);
out.apartOff = doc.더한줄[0].별도계상 ?? null;
const opened = { 덮개: {} };
setQty(opened, by("labor:A#0"), 1.5, true);
setLineCover(opened, by("labor:A#0"), { qty: 0.15 }, true); // 바탕 품 0.1 → 배율 1.5
out.scale = opened.덮개["labor:A#0"];
setQty(opened, by("labor:A#0"), 1, true);
setLineCover(opened, by("labor:A#0"), { qty: 0.1 }, true);
out.scaleBack = opened.덮개["labor:A#0"] ?? null;
dropLine(doc, by("labor:A#0"));
out.drop = [doc.뺀줄, doc.덮개["labor:A#0"] ?? null];
@@ -94,6 +98,24 @@ const covered = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.09, 단가
out.revised = rowsOf(bare, bare, covered).find((r) => r.cid === "labor:A#0").revised;
out.sheet = sheetGroups(rowsOf(bare, bare, {}))
.map((g) => [g.비목, g.소계, g.줄.map((x) => x.금액)]);
// 공용 상세 줄 — 바탕 줄 그대로 · 뺀 줄 없음 · 더한줄 · 별도계상 빠진 줄 = 빈 칸 · 덧줄
const base = { 호표: [{ 종류: "인력", 요소: "A", 이름: "석공", 수량: "0.1*x" },
{ 종류: "로직", 요소: "로직(G)", 수량: "2" }, { 종류: "재료", 요소: "M", 수량: "0.5" }],
덧줄: [{ 이름: "공구손료", 식: "노무비 * 3 / 100" }] };
const doc2 = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.1 } } }, 뺀줄: ["logic:G#0"],
더한줄: [{ 종류: "인력", 요소: "LB2", 이름: "보통인부", 비목: "노무비",
수량: 0.01, 단가: 500 }] };
const ans2 = { ...bare, 정체: ["labor:A#0", "material:M#0", "add:#0", "pct:공구#0"],
줄: [line("labor:A#0", "석공", 0.3, 1000, { 노무비: 300 }, "인력"),
line("add:#0", "보통인부", 0.01, 500, { 노무비: 5 }, "인력"),
{ ...line("pct:공구#0", "공구손료", 9.15, 1, { 경비: 9.15 }, "경비"), 율: 3 }] };
const v = hoView(ans2, bare, base, doc2);
out.view = [v.호표.map((x) => [x.종류, x.요소, x.수량, x.비목 ?? null]), v.덧줄, v.cids,
v.lines.map((l) => l?.cid ?? null), v.covers];
out.viewRef = hoView(ans2, bare, null, doc2).호표[0];
out.baseCovers = baseCovers(bare, 3, doc2);
const text = { ok: true, groups: [{ 비목: "노무비", 줄: [{ 이름: "석공" }, { 이름: "굴착기" }] }] };
out.cut = dropText(text, ["석공", "굴착기", "모르타르"], bare, doc2).groups[0].줄.map((x) => x.이름);
console.log(JSON.stringify(out));
"""
)
@@ -107,13 +129,14 @@ console.log(JSON.stringify(out));
assert got["qty"] == {"qty": 0.2, "원값": {"qty": 0.1, "단가": 1000}}
assert got["qtyBack"] is None
assert got["price"] == {"labor": 1200, "원값": {"qty": 0.1, "단가": 1000}}
assert got["unset"] is None
assert got["split"]["labor"] == 60 and got["split"]["material"] == 30
assert got["split"]["expense"] == 10
assert got["added"]["수량"] == 3 and got["added"]["단가"] == 350
# 더한 재료 줄만 별도계상 고름 · 켬 = 칸 true · 끔 = 칸 지움(기본 계 안)
assert got["pick"] == [True, False]
assert got["apartOn"] is True and got["apartOff"] is None
assert got["scale"]["배율"] == 1.5 and "qty" not in got["scale"]
assert got["scale"]["배율"] == pytest.approx(1.5) and "qty" not in got["scale"]
assert got["scaleBack"] is None
assert got["drop"] == [["labor:A#0"], None]
assert got["restore"] == [] and got["dropAdded"] == 0
@@ -124,6 +147,21 @@ console.log(JSON.stringify(out));
["재료비", 30, [30, None]],
["경비", 10, [10]],
]
ho, extra, cids, lines, covers = got["view"]
assert ho == [
["인력", "A", "0.1*x", None], # 바탕 줄 그대로(식)
["재료", "M", "0.5", None],
["인력", "LB2", "0.01", "노무비"], # 더한줄
]
assert extra == [{"이름": "공구손료", "식": "노무비 * 3 / 100"}]
assert cids == ["labor:A#0", "material:M#0", "add:#0", "pct:공구#0"]
assert lines == ["labor:A#0", None, "add:#0", "pct:공구#0"] # 별도계상 빠진 재료 = 빈 칸
assert covers == {"0": {"qty": 0.3}, "2": {"qty": 0.01, "labor": 500}}
assert got["viewRef"] == { # 참조 바탕 = 셈 줄로 지음
"종류": "인력", "요소": "", "이름": "석공", "단위": "인", "수량": "0.3", "비목": "노무비",
} # fmt: skip
assert got["baseCovers"] == {"0": {"qty": 0.3}}
assert got["cut"] == ["석공"] # 뺀 바탕 줄 글 거름(같은 이름 더한줄이 안 받음)
def test_상세_부품_길_부품상자():
@@ -135,6 +173,11 @@ def test_상세_부품_길_부품상자():
assert "buildLabDetail(editor" in detail and "cover: { ho, est }" in detail
assert "buildCalc(calcHost" in detail and "openSangeunLinePicker(scope" in detail
assert "if (mine !== seq || !alive) return" in detail # 최신 응답만
# 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음
assert "...buildHo({" in detail and 'scope: "m02",' in detail
assert "setLineCover(doc, r, c, opened());" in detail and "skCoverTable" not in detail
ho = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts")
assert "edit.more?.(e.idx, close)" in ho and '"EditedM02"' in ho
for s in (detail, cover, _text(MODEL)):
assert "<button" not in s and "<input" not in s and "<table" not in s
assert "document.createElement" not in s and "sessionStorage." not in s
+18 -39
View File
@@ -1,9 +1,11 @@
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
"""일위대가 줄 [단가산출 보기] 모달(PLAN 717-3) — 임시 storage · 순수 함수는 Node 로.
"""일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 724-1) — 임시 storage · 순수 함수는 Node 로.
화면 문서(`M02_Sangeun_Cover_Model.lineDoc`) = 서버 `M02_Labor_Patch.as_doc` 꼴(같은 고름 · 자재표시 cid) ·
화면 문서(`logic/M02_Sangeun_Cover_Model.lineDoc`) = 서버 `M02_Labor_Patch.as_doc` 꼴
(같은 고름 · 자재표시 cid) ·
모달 셈 길(`/sangeun/calc` = `calc_doc`) 값 = 일위대가 셈의 그 줄 값 · 고친 칸 = 덮개 칸 이름 ·
덮개 고침 순수 함수 · 계산서 묶음 · `normalizeLabor` 가 줄 `패치` 를 지킴 · 화면 이음.
패치 정리 순수 함수 · `normalizeLabor` 가 줄 `패치` 를 지킴 ·
화면 이음(모달 = 줄 모드 SK 상세 · 공용 상세).
"""
import json
@@ -32,7 +34,7 @@ const fs = require("fs");
const path = require("path");
const root = process.argv[1];
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const f = path.join(root, "M02_MasterTemplete", "M02_Sangeun_Cover_Model.ts");
const f = path.join(root, "M02_MasterTemplete", "logic", "M02_Sangeun_Cover_Model.ts");
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
@@ -116,45 +118,20 @@ def test_모달_셈_길_값_일위대가_줄_값(world):
assert cover["덮개"] == ["qty"] and cover["수량"] == pytest.approx(0.5)
def test_덮개_순수_함수_계산서_묶음():
"""칸 고침 · 풂 · 원값 뺌 · 단가 칸 비목 · 묶음 = 비목마다 서버 값 그대로."""
lines = [
{"cid": "labor:LB1#0", "이름": "보통인부", "수량": 0.2, "단가": 100, "금액": 20,
"비목": {"노무비": 20, "재료비": 0, "경비": 0}},
{"cid": "logic:GC1#0", "이름": "하위", "수량": 1, "금액": 30,
"비목": {"노무비": 10, "재료비": 20, "경비": 0}},
{"cid": "pct:공구#0", "이름": "공구손료", "수량": 0.6, "율": 3, "금액": 0.6,
"비목": {"노무비": 0, "재료비": 0, "경비": 0.6}},
] # fmt: skip
calc_ = {"기준": {"노무비": 30, "재료비": 20, "경비": 0.6, "계": 50.6}, "줄": lines}
one = {"덮개": {"labor:LB1#0": {"qty": 0.5, "원값": {"qty": 0.2}}}}
def test_패치_정리_순수_함수():
"""원값 뺌 · 빈 칸 뺌 · 다 비면 null(줄 칸 안 둠) — 덮개 고침 자체는 717-2b `setLineCover`."""
one = {"덮개": {"labor:LB1#0": {"qty": 0.5, "원값": {"qty": 0.2}}, "x:a#0": {"원값": {}}}}
got = _node(
{
"set": ["setCover", None, "labor:LB1#0", "labor", 120],
"qty": ["setCover", {"덮개": {"x:a#0": {"배율": 2}}}, "x:a#0", "qty", 1],
"off": ["setCover", {"덮개": {"labor:LB1#0": {"qty": 1}}}, "labor:LB1#0", "qty", None],
"clean": ["cleanPatch", one],
"empty": ["cleanPatch", {"덮개": {}, "뺀줄": [], "더한줄": []}],
"none": ["cleanPatch", None],
"keep": ["cleanPatch", {"뺀줄": ["a:b#0"]}],
"p0": ["priceKey", lines[0]],
"p1": ["priceKey", lines[1]],
"p2": ["priceKey", lines[2]],
"q2": ["qtyOf", lines[2]],
"groups": ["sheetGroups", calc_],
}
)
assert got["set"] == {"덮개": {"labor:LB1#0": {"labor": 120}}}
assert got["qty"] == {"덮개": {"x:a#0": {"qty": 1}}} # 품은 qty · 배율 중 하나
assert got["off"] is None and got["empty"] is None
assert got["clean"] == {"덮개": {"labor:LB1#0": {"qty": 0.5}}} # 원값 = 서버 몫
assert got["empty"] is None and got["none"] is None
assert got["keep"] == {"뺀줄": ["a:b#0"]}
assert (got["p0"], got["p1"], got["p2"], got["q2"]) == ("labor", None, None, 3)
groups = got["groups"]
assert [g["비목"] for g in groups] == ["노무비", "재료비", "경비"]
assert [g["소계"] for g in groups] == [30, 20, 0.6]
assert groups[0]["줄"][0] == {"이름": "보통인부", "글": "100 * 0.2 = 20", "금액": 20}
assert [x["이름"] for x in groups[0]["줄"]] == ["보통인부", "하위"]
assert groups[2]["줄"][0]["글"] == "3 % = 0.6"
def test_normalizeLabor_줄_패치_지킴():
@@ -182,17 +159,19 @@ def test_화면_이음():
assert "const sk = opt.calc ? {} : { sangeun: openSk };" in detail
assert 'edit && opt.layer === "personal" && !library.owner' in detail
modal = (M02 / "M02_MasterTemplete_Labor_SangeunModal.ts").read_text("utf-8")
assert "same(patch, applied) || askConfirm(" in modal
assert '{ layer: "personal", owner: null }' in modal # 새 SK = 내 개인 층
assert "if (mine !== seq) return;" in modal # 최신 응답만
assert "mountRowSk(host," in modal # 724-1 — 줄 모드 SK 상세(공용 상세)
assert "!done && sk.take() && !(await askConfirm(" in modal # 적용 안 한 고침 물음
assert "saveAsSk(o.row, now(), name, o.unit)" in modal # 새 SK = 내 개인 층(saveAsSk)
sk = (M02 / "M02_MasterTemplete_Labor_RowSk.ts").read_text("utf-8")
assert '{ layer: "personal", owner: null }' in sk
cover = (M02 / "M02_MasterTemplete_Sangeun_Cover.ts").read_text("utf-8")
assert "/sangeun/calc" in cover and "/sangeun/from-line" in cover
assert 'tr.classList.add("is-changed")' in cover # 표 부품 「바뀜」 상태
assert "coverTable" not in cover and not (M02 / "M02_Sangeun_Cover_Model.ts").exists()
for name in (
"M02_MasterTemplete_Labor_Rows.ts",
"M02_MasterTemplete_Labor_Detail.ts",
"M02_MasterTemplete_Labor_SangeunModal.ts",
"M02_MasterTemplete_Sangeun_Cover.ts",
"M02_Sangeun_Cover_Model.ts",
"logic/M02_Sangeun_Cover_Model.ts",
):
assert len((M02 / name).read_text("utf-8").splitlines()) <= 700, name