- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전 - 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음) - 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로] - 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로) - 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음) - 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
16 KiB
Python
313 lines
16 KiB
Python
"""790-1 M02 단가산출 줄 식 시트(E1) — 상세 첫 탭 「시트」 · 줄 갈래 · 산출서 보기 · 부품 상자만.
|
||
|
||
- 정적: 시트 화면 = 부품 상자(calc_row 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글)만 · 날 button · input 0 ·
|
||
상세 탭 등록(시트 첫 칸 · 옛 보기 넷 그대로) · 고침 = SK 덮개 · 뺀줄 · 되살림 길(모델 안 바꿈)
|
||
- 순수 셈(node strip-types): 줄 갈래(근거 · 옵션 · 상수 · 표 · 계산 · 자원 · 율 · 소계 · 합계) · 엔진 날식 숨김 ·
|
||
읽기 쉬운 식 · 산출서 한 줄 글 · 상태줄 금액 셋(790-2 `원래` 있으면 그 값)
|
||
"""
|
||
|
||
import json
|
||
import re
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
M02 = ROOT / "M02_MasterTemplete"
|
||
SHEET = M02 / "M02_MasterTemplete_Sangeun_Sheet.ts"
|
||
MODEL = M02 / "logic" / "M02_Sangeun_Sheet_Model.ts"
|
||
DETAIL = M02 / "M02_MasterTemplete_Sangeun_Detail.ts"
|
||
ROW = ROOT / "ui_template" / "ui_template_calc_row.ts"
|
||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||
|
||
|
||
def _text(p: Path) -> str:
|
||
return p.read_text("utf-8")
|
||
|
||
|
||
def test_부품_상자만_날_요소_없음():
|
||
for p in (SHEET, MODEL, SHEET.with_suffix(".css")):
|
||
s = _text(p)
|
||
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
|
||
assert len(s.splitlines()) < 700
|
||
sheet = _text(SHEET)
|
||
for part in ("createCalcSheetRow", "createInputField", "createSelectField", "createButton"):
|
||
assert f"{part}" in sheet
|
||
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
|
||
css = _text(SHEET.with_suffix(".css"))
|
||
assert not re.search(r"#[0-9a-fA-F]{3,6}\b|rgb\(", css) # 색 = 공통 값만
|
||
row = _text(ROW)
|
||
assert "export function createCalcSheetRow(" in row and "ui-calc-row--sheet" in row
|
||
|
||
|
||
def test_상세_탭_등록_시트_첫_칸():
|
||
detail = _text(DETAIL)
|
||
assert "createSangeunSheet(editor," in detail and "createViewPicker" not in detail
|
||
assert "children: [sheet.picker, addButton, rateButton]" in detail
|
||
assert re.search(r"sheet\.host,\s+editor,", detail) # 시트 그릇 = 옛 보기 그릇 앞
|
||
assert (
|
||
"sheet.draw({ doc, ans, bare, base: row, text, rows, view, values, removed, fail });"
|
||
in detail
|
||
)
|
||
assert "sheet.saved();" in detail
|
||
assert len(detail.splitlines()) <= 575
|
||
sheet = _text(SHEET)
|
||
opts = re.search(r"options: \[\s*\{ value: \"sheet\"", sheet)
|
||
assert opts, "시트가 보기 고르기 첫 칸"
|
||
for v in ('"ho"', '"est"', '"calc"', '"formula"', '"paper"'):
|
||
assert f"value: {v}" in sheet # 옛 보기 넷 그대로 · 산출서 전환
|
||
# 고침 길 = 지금 SK 모델(덮개 · 뺀줄 · 되살림) · 다시 셈 = 상세 touch(서버 셈 · 최신 응답만)
|
||
for call in (
|
||
"setLineCover(d.doc, r,",
|
||
"dropLine(d.doc, r)",
|
||
"restoreLine(d.doc, cid)",
|
||
"opt.touch()",
|
||
):
|
||
assert call in sheet
|
||
assert (
|
||
"addLine: () => addButton.click()" in detail
|
||
and "addRate: () => rateButton.click()" in detail
|
||
)
|
||
|
||
|
||
FIX = {
|
||
"base": {
|
||
"출처": "건설표준품셈 공통 6-3-1",
|
||
"원문번호": "6-3-1",
|
||
"비고": "※ 높이 3.5 m 이하",
|
||
"입력": [{"이름": "사용횟수", "고르기": ["3회", "6회"]}],
|
||
"중간": [
|
||
{"이름": "상수", "식": "17"},
|
||
{"이름": "시공량", "식": "찾기(QC000431, 유형=유형).시공량"},
|
||
{"이름": "형틀목공", "식": "찾기(QC000431, 유형=유형).형틀목공 / 시공량"},
|
||
{"이름": "합판율", "식": "만약(사용횟수 == '1회', 1, 찾기(QC000428, 사용횟수=사용횟수).합판 / 100)"},
|
||
],
|
||
},
|
||
"values": {"사용횟수": "6회"},
|
||
"ans": {
|
||
"기준": {"노무비": 38290, "재료비": 12747, "경비": 382, "계": 51419},
|
||
"중간": {"상수": 17, "시공량": 45, "형틀목공": 0.1111, "합판율": 0.327},
|
||
"원래": None,
|
||
},
|
||
"rows": [
|
||
{"cid": "labor:LB000007#0", "이름": "형틀목공", "단위": "인", "수량": 0.1111, "단가": 275790,
|
||
"금액": 30643.3, "parts": {"노무비": 30643.3}, "pct": False, "added": None, "apart": False,
|
||
"covered": {"qty": False, "price": False}, "off": False},
|
||
{"cid": "material:폼타이#0", "이름": "폼타이", "단위": "본", "수량": 0, "단가": 0, "금액": 0,
|
||
"parts": {"재료비": 0}, "pct": False, "added": None, "apart": False,
|
||
"covered": {"qty": False, "price": False}, "off": True},
|
||
{"cid": "add:#0", "이름": "보통인부", "단위": "인", "수량": 0.1, "단가": 172068, "금액": 17206.8,
|
||
"parts": {"노무비": 17206.8}, "pct": False, "added": 0, "apart": False,
|
||
"covered": {"qty": False, "price": False}, "off": False},
|
||
{"cid": "pct:공구손료#0", "이름": "공구손료", "단위": "%", "수량": 2, "단가": None, "금액": 765.8,
|
||
"parts": {"경비": 765.8}, "pct": True, "added": None, "apart": False,
|
||
"covered": {"qty": True, "price": False}, "off": False},
|
||
],
|
||
"text": {
|
||
"ok": True,
|
||
"계": 51419,
|
||
"별칭": [{"이름": "합판거푸집일당", "원문": "찾기(QC000431, 유형=유형)"}],
|
||
"groups": [
|
||
{"비목": "노무비", "소계": 38290, "끝수": "", "줄": [
|
||
{"이름": "형틀목공", "cid": "labor:LB000007#0", "글": "275,790 * 0.1111 = 30,643.3",
|
||
"이름글": "형틀목공노임 * 형틀목공 = 형틀목공비", "금액": 30643.3},
|
||
{"이름": "폼타이", "cid": "material:폼타이#0", "글": "0 * 0 = 0", "금액": 0, "옵션꺼짐": True},
|
||
{"이름": "보통인부", "cid": "add:#0", "글": "", "금액": 17206.8},
|
||
]},
|
||
{"비목": "경비", "소계": 765, "끝수": "", "줄": [
|
||
{"이름": "공구손료", "cid": "pct:공구손료#0", "글": "(30,643.3) * 2 % = 765.8",
|
||
"이름글": "(형틀목공 * 형틀목공노임) * 2 % = 공구손료", "금액": 765.8},
|
||
]},
|
||
],
|
||
},
|
||
} # fmt: skip
|
||
|
||
|
||
def _run():
|
||
src = IMPORTS.sub("", _text(MODEL))
|
||
body = (
|
||
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
|
||
"\nconst items = sheetItems({ base: F.base, inputs: F.base.입력, values: F.values, ans: F.ans,"
|
||
" rows: F.rows, text: F.text });"
|
||
"\nconst out = {"
|
||
"\n items: items.map((x) => ({ kind: x.kind, 이름: x.이름, 식: x.식, 값: x.값, 비목: x.비목,"
|
||
" 표: x.표, changed: x.changed })),"
|
||
"\n paper: paperLines(F.text, F.rows, F.ans),"
|
||
"\n status: statusOf(F.ans, { 기준: { 계: 51000 } }, 50000),"
|
||
"\n origin: statusOf({ ...F.ans, 원래: { 계: 49000 } }, { 기준: { 계: 51000 } }, null),"
|
||
"\n diff: [diffText(51802, 51419), diffText(51000, 51419), diffText(1, 1)],"
|
||
"\n};"
|
||
"\nconsole.log(JSON.stringify(out));"
|
||
)
|
||
result = subprocess.run(
|
||
["node", "--experimental-strip-types", "--no-warnings",
|
||
"--input-type=module-typescript", "-"],
|
||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||
) # fmt: skip
|
||
assert result.returncode == 0, result.stderr
|
||
return json.loads(result.stdout)
|
||
|
||
|
||
def test_줄_갈래_읽기_쉬운_식_산출서_상태줄():
|
||
got = _run()
|
||
kinds = [x["kind"] for x in got["items"]]
|
||
assert kinds == ["근거", "근거", "옵션", "상수", "표", "계산", "계산", "자원", "자원", "율",
|
||
"소계", "소계", "소계", "합계"] # fmt: skip
|
||
by = {(x["kind"], x["이름"]): x for x in got["items"]}
|
||
assert by[("근거", "6-3-1")]["식"] == "건설표준품셈 공통 6-3-1"
|
||
assert by[("표", "시공량")]["값"] == 45 and by[("표", "시공량")]["표"] == ["QC000431"]
|
||
assert by[("계산", "형틀목공")]["식"] == "합판거푸집일당.형틀목공 / 시공량"
|
||
assert by[("계산", "합판율")]["식"] == "" and by[("계산", "합판율")]["표"] == [
|
||
"QC000428"
|
||
] # 날식 숨김
|
||
res = by[("자원", "형틀목공 (인)")]
|
||
assert res["식"] == "형틀목공노임 × 형틀목공" and res["비목"] == "노" and not res["changed"]
|
||
assert ("자원", "폼타이 (본)") not in by # 옵션으로 빠진 줄 안 그림
|
||
assert by[("자원", "보통인부 (인)")]["changed"] # 더한줄 = M01 과 다름
|
||
assert by[("자원", "보통인부 (인)")]["식"] == "172,068 × 0.1" # 이름글 없음 = 단가 × 품
|
||
rate = by[("율", "공구손료")]
|
||
assert (
|
||
rate["식"] == "(형틀목공 × 형틀목공노임) × 2 %" and rate["changed"] and rate["비목"] == "경"
|
||
)
|
||
assert by[("합계", "계")]["값"] == 51419
|
||
paper = [p["글"] for p in got["paper"]]
|
||
assert "형틀목공 : 275,790 × 0.1111 = 30,643.3" in paper
|
||
assert "보통인부 : 172,068 × 0.1 = 17,206.8" in paper # 글 없음 = 표 줄로 지음
|
||
assert not any(p.startswith("폼타이") for p in paper)
|
||
assert "노무비 소계 = 38,290" in paper and paper[-1] == "계 = 51,419"
|
||
assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계
|
||
assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값
|
||
assert got["diff"] == ["+383", "−419", ""]
|
||
|
||
|
||
# ── 790-3 E2 화면 이음(식 속 상수 · 표값 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜) ──────────
|
||
EDIT = M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts"
|
||
|
||
|
||
def test_790_3_숫자_넓힘_칸_정적():
|
||
s = _text(EDIT)
|
||
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||
assert "fetch(" not in s and "sessionStorage." not in s and len(s.splitlines()) < 700
|
||
for call in (
|
||
"setConst(c.doc, spot, p.n, x)", # 식 속 숫자 = 상수 덮개
|
||
"setTable(c.doc, spot, p.n, x)", # 표 꼬리표 값 = 표값 덮개
|
||
'createBadge(t("Cut"), "warning")', # 표 연결 끊김
|
||
"addText(c.doc, spot,", # 글 줄 더함 · 고침 · 지움
|
||
"editText(c.doc, i, v)",
|
||
"dropText(c.doc, i)",
|
||
"setReason(c.doc, key, x)", # 까닭
|
||
"restoreM01(c.doc, spot)", # 줄마다 M01 원래로
|
||
"restoreM01(c.doc)", # 전체
|
||
'createBadge(t("Changed"), "danger")', # M01 바뀜
|
||
"c.spots?.짝잃음",
|
||
):
|
||
assert call in s, call
|
||
sheet = _text(SHEET)
|
||
for call in (
|
||
"formulaEditor(ctx, it.자리)",
|
||
"reasonCell(ctx, it.자리, it.changed)",
|
||
"spotTail(ctx, it.자리,",
|
||
"restoreAll(ctx)",
|
||
"lostBanner(ctx)",
|
||
"removedReasons(ctx, d.removed)",
|
||
"dropAddKeys(d.doc as ConstDoc, r.added)", # 뺀 더한줄 = 까닭 · 글줄 열쇠 당김
|
||
"liftAddKeys(d.doc as ConstDoc, at)", # 되살림 = 거꾸로 밂
|
||
"paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄)", # 글 줄 산출서에
|
||
"spots: spotsOf(d.ans)",
|
||
):
|
||
assert call in sheet, call
|
||
assert "Object.assign(doc, next);" in sheet # 상세가 쥔 같은 문서 객체 · 자동저장 없음
|
||
assert len(sheet.splitlines()) < 700
|
||
|
||
|
||
SPOT = {
|
||
"식": "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)",
|
||
"상수": [{"n": 0, "값": 1, "원값": 1}, {"n": 1, "값": 0, "원값": 0},
|
||
{"n": 2, "값": 17, "원값": 17}, {"n": 3, "값": 100, "원값": 100}],
|
||
"표값": [{"n": 0, "글": "찾기(QC000431, 유형=유형).시공량", "값": 45, "원값": 45}],
|
||
} # fmt: skip
|
||
|
||
|
||
def _run_e2():
|
||
src = IMPORTS.sub("", _text(MODEL))
|
||
bad = {**SPOT, "상수": SPOT["상수"][:3]}
|
||
body = (
|
||
f"\nconst S = {json.dumps(SPOT, ensure_ascii=False)};"
|
||
f"\nconst B = {json.dumps(bad, ensure_ascii=False)};"
|
||
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
|
||
"\nconst show = (p) => p && p.map((x) => x.t === '글' ? x.글 : x.t === '수' ? `[#${x.n}]` : `[표${x.n} ${x.표}]`).join('');"
|
||
"\nconst notes = [{ 자리: '', 글: '맨 위 글' }, { 자리: 'labor:LB000007#0', 글: '형틀 뒤 글' },"
|
||
" { 자리: 'material:폼타이#0', 글: '꺼진 줄 뒤' }, { 자리: 'labor:LB000007#0', 글: '' }];"
|
||
"\nconsole.log(JSON.stringify({"
|
||
"\n parts: show(formulaParts(S)),"
|
||
"\n bad: formulaParts(B),"
|
||
"\n none: formulaParts({ 식: '형틀목공', 상수: [], 표값: [] }),"
|
||
"\n spots: numSpots(\"만약(a == '3회', LB000007 * 2.5, 줄.'합판 12㎜' / 10)\"),"
|
||
"\n paper: paperLines(F.text, F.rows, F.ans, notes).map((p) => p.kind + ':' + p.글),"
|
||
"\n lift: liftAddKeys({ 까닭: { 'add:#0': 'a', 'add:#1/x#0': 'b', 'mid:q': 'c' },"
|
||
" 글줄: [{ 자리: 'add:#2', 글: 'g' }] }, 1),"
|
||
"\n spotsNone: spotsOf(null),"
|
||
"\n}));"
|
||
) # fmt: skip
|
||
result = subprocess.run(
|
||
["node", "--experimental-strip-types", "--no-warnings",
|
||
"--input-type=module-typescript", "-"],
|
||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||
) # fmt: skip
|
||
assert result.returncode == 0, result.stderr
|
||
return json.loads(result.stdout)
|
||
|
||
|
||
def test_790_3_식_조각_글줄_산출서_열쇠_밂():
|
||
got = _run_e2()
|
||
assert got["parts"] == (
|
||
"[표0 QC000431] × ([#0] - (높이감률 + 만약(지붕슬래브 == '아님', [#1], [#2])) / [#3])"
|
||
) # 서버 scan 차례 그대로 · 표 찾기 = 한 칸
|
||
assert got["bad"] is None and got["none"] is None # 차례가 안 맞으면 숫자 칸 안 그림
|
||
assert len(got["spots"]) == 2 # 2.5 · 10 만 — 따옴표 글(3회 · 12㎜) · 이름(LB000007) 안 숫자 뺌
|
||
paper = got["paper"]
|
||
assert paper[0] == "글:맨 위 글" and paper[1] == "글:꺼진 줄 뒤" # 맨 위 · 안 그린 줄 = 위로
|
||
i = paper.index("줄:형틀목공 : 275,790 × 0.1111 = 30,643.3")
|
||
assert paper[i + 1] == "글:형틀 뒤 글" and "글:" not in paper[i + 2] # 빈 글 안 찍음
|
||
assert got["lift"]["까닭"] == {"add:#0": "a", "add:#2/x#0": "b", "mid:q": "c"}
|
||
assert got["lift"]["글줄"] == [{"자리": "add:#3", "글": "g"}]
|
||
assert got["spotsNone"] is None
|
||
|
||
|
||
# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ────────────────────────────
|
||
FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts"
|
||
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts" # 794-1 식 칸 몸 = 공용(M01 시트와 한 벌)
|
||
|
||
|
||
def test_792_식_칸_정적():
|
||
s = _text(FX)
|
||
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||
assert "sessionStorage." not in s and len(s.splitlines()) < 700
|
||
both = s + _text(CELL)
|
||
for call in (
|
||
"/sangeun/formula-check", # 검사 길(셈 · 저장 없음)
|
||
"ctl?.abort();", # 칠 때마다 · 옛 요청 끊기
|
||
"signal,",
|
||
'box.input.addEventListener("input"',
|
||
'box.setError(got.되나 ? undefined : got.까닭.join(" · "))', # 빨간 칸 + 까닭
|
||
"if (!got?.되나 || !got.식) return", # 틀린 식은 문서에 안 넣음
|
||
"setFormula(doc, spot, engine)", # 엔진 식을 문서에
|
||
"clearNums(c.doc as FormulaDoc, spot)", # 같은 자리 상수 · 표값 · 품 덮개 걷음
|
||
"addVar(c.doc as FormulaDoc,",
|
||
"editVar(c.doc as FormulaDoc, v.이름, { 이름: to })",
|
||
"dropVar(c.doc as FormulaDoc, v.이름)",
|
||
):
|
||
assert call in both, call
|
||
sheet = _text(SHEET)
|
||
for call in (
|
||
"rowFormula(ctx, it.자리,",
|
||
"formulaToggle(ctx, it.자리,",
|
||
"varRows(ctx, (v) => v.자리 === it.자리)",
|
||
"addVarButton(ctx)",
|
||
"scope: opt.scope,",
|
||
'coverKinds(d.doc as ConstDoc, r.cid).includes("식")', # 식 덮개 = 품 칸 안 둠
|
||
):
|
||
assert call in sheet, call
|
||
assert "addRate: () => rateButton.click(), scope }" in _text(DETAIL) # 탭 등록 줄에 층만
|
||
assert "fetch(" not in s # 길 = Api_Fetch.call(로그인 쿠키)
|