feat(M01): 44-5 체인톱 가격 · 오일 자재품목 연결 — 손 입력 칸 걷음

- GF000002 작업로 설치 · GF000056 어린나무 가꾸기 · GF000076 방제 벌목조재 — 입력 체인톱가격 · 체인오일단가 걷음
- 체인오일 = 호표 재료 줄(검색어 「작동유」 → 기어작동유 4,612원/ℓ) · 체인톱 손료 = 호표 재료 줄(검색어 「동력체인톱」 → 기본 400mm 530,000원 · 비목 경비 · 규격 고름 가능)
- 잡품 = 휘발유 줄 금액 × 40%(주⑤ · 오일이 재료비에 들어와 식 대상 좁힘)
- 같은 단가면 원가 전후 같음(GF000002 계 454,056) · 손 입력 빈칸 견본 대비 체인오일 · 손료가 채워짐
- 시험 — test_m01_auto 빈 원 입력 시험을 GF000051 예취기가격으로 옮김 · 체인톱 자재 시험 추가 · ui_checks/44-5_체인톱_자재.js

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
2026-10-05 11:31:58 +09:00
co-authored by Claude Opus 5.5
parent a7d17a6c25
commit bc094969ac
6 changed files with 152 additions and 51 deletions
@@ -25,11 +25,9 @@
"결과단위": "원/km",
"출처": "산림품셈 3-3",
"소유": "공용",
"비고": "할인·할증 못 넣음 — 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-9 제거대상 식생(조건 「제거대상 식생」 빈칸) — 칸 이름 · 범위규칙 문법 · 체인톱 가격 · 체인오일 단가 원문에 없음(시중가격) — 설계자 입력 · 상층목은 솎아베기 본수에 포함(주③⑥)",
"비고": "할인·할증 못 넣음 — 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-9 제거대상 식생(조건 「제거대상 식생」 빈칸) — 칸 이름 · 범위규칙 문법 · 체인톱 가격 = 자재품목 「동력체인톱」(기본 = 첫 규격 · 고를 수 있음) · 체인오일 = 자재품목 「작동유」 · 상층목은 솎아베기 본수에 포함(주③⑥)",
"입력": [
{ "이름": "구분", "고르기": ["소작업로", "대작업로"] },
{ "이름": "체인톱가격", "단위": "원" },
{ "이름": "체인오일단가", "단위": "원/ℓ" },
{
"이름": "지역",
"고르기": [
@@ -86,22 +84,33 @@
"수량": "체인톱대수 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주③"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "작동유" },
"이름": "체인오일",
"단위": "ℓ",
"수량": "체인톱대수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "동력체인톱" },
"이름": "체인톱 손료",
"단위": "대",
"수량": "체인톱대수 * EQ000620.손료계수",
"비목": "경비",
"출처": "산림품셈 2-2-1"
}
],
"덧줄": [
{
"이름": "잡품",
"식": "재료비 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"식": "줄.'휘발유(체인톱)' * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주⑤"
},
{
"이름": "체인오일",
"식": "체인톱대수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료 * 체인오일단가",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{ "이름": "체인톱 손료", "식": "체인톱대수 * EQ000620.손료계수 * 체인톱가격", "비목": "경비", "출처": "산림품셈 2-2-1" }
}
],
"끝수": null
},
@@ -363,12 +363,10 @@
"결과단위": "원/ha",
"출처": "산림품셈 6-5",
"소유": "공용",
"비고": "소작업로 = ha당 소작업로 길이(없으면 0 · 인력구성 특별 1.0 · 보통 1.0 — 소요인력 산출 1) · 해석: 체인톱 대수 = 특별인부 100%(2-1-1 비고) · 체인톱 가격 · 체인오일 단가 원문에 없음(시중가격) — 설계자 입력 · 덩굴걷기 추가는 6-4-1 따로(주③) · 할인·할증 못 넣음 — 1-4-1 작업시기(범위규칙이 글) · 1-4-3 집단화정도(조건 「개소당 평균면적」 빈칸 · 범위규칙이 글) · 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-12 제거대상 피복도(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"비고": "소작업로 = ha당 소작업로 길이(없으면 0 · 인력구성 특별 1.0 · 보통 1.0 — 소요인력 산출 1) · 해석: 체인톱 대수 = 특별인부 100%(2-1-1 비고) · 체인톱 가격 = 자재품목 「동력체인톱」(기본 = 첫 규격 · 고를 수 있음) · 체인오일 = 자재품목 「작동유」 · 덩굴걷기 추가는 6-4-1 따로(주③) · 할인·할증 못 넣음 — 1-4-1 작업시기(범위규칙이 글) · 1-4-3 집단화정도(조건 「개소당 평균면적」 빈칸 · 범위규칙이 글) · 1-4-5 경사도(값 칸 「할인·할증률」) · 1-4-12 제거대상 피복도(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"입력": [
{ "이름": "대상지유형", "고르기": ["치수림 단계", "유령림 단계"] },
{ "이름": "소작업로", "단위": "km/ha" },
{ "이름": "체인톱가격", "단위": "원" },
{ "이름": "체인오일단가", "단위": "원/ℓ" },
{
"이름": "지역",
"고르기": [
@@ -425,22 +423,33 @@
"수량": "특별 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주③"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "작동유" },
"이름": "체인오일",
"단위": "ℓ",
"수량": "특별 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "동력체인톱" },
"이름": "체인톱 손료",
"단위": "대",
"수량": "특별 * EQ000620.손료계수",
"비목": "경비",
"출처": "산림품셈 2-2-1"
}
],
"덧줄": [
{
"이름": "잡품",
"식": "재료비 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"식": "줄.'휘발유(체인톱)' * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주⑤"
},
{
"이름": "체인오일",
"식": "특별 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료 * 체인오일단가",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{ "이름": "체인톱 손료", "식": "특별 * EQ000620.손료계수 * 체인톱가격", "비목": "경비", "출처": "산림품셈 2-2-1" }
}
],
"끝수": null
},
@@ -50,7 +50,7 @@
"결과단위": "원/㎥",
"출처": "산림품셈 8-3",
"소유": "공용",
"비고": "경급 52㎝ 이상은 50(주③) · 해석: 체인톱 대수 = 벌목부 100%(2-1-1 비고) · 체인톱 가격 · 체인오일 단가 원문에 없음(시중가격) — 설계자 입력 · 할인·할증 못 넣음 — 1-4-8 장애물의 정도(조건 「장애물의 정도」 빈칸) · 1-4-17 방제대상목 분포(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"비고": "경급 52㎝ 이상은 50(주③) · 해석: 체인톱 대수 = 벌목부 100%(2-1-1 비고) · 체인톱 가격 = 자재품목 「동력체인톱」(기본 = 첫 규격 · 고를 수 있음) · 체인오일 = 자재품목 「작동유」 · 할인·할증 못 넣음 — 1-4-8 장애물의 정도(조건 「장애물의 정도」 빈칸) · 1-4-17 방제대상목 분포(조건 빈칸 · 값 칸 「할인·할증률」) — 칸 이름 · 범위규칙 문법",
"입력": [
{
"이름": "경급",
@@ -59,8 +59,6 @@
},
{ "이름": "소군락", "고르기": ["소군락 모두베기", "해당 없음"] },
{ "이름": "방제수종", "고르기": ["잣나무", "소나무, 해송"] },
{ "이름": "체인톱가격", "단위": "원" },
{ "이름": "체인오일단가", "단위": "원/ℓ" },
{
"이름": "지역",
"고르기": [
@@ -122,22 +120,33 @@
"수량": "벌목부수 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주③"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "작동유" },
"이름": "체인오일",
"단위": "ℓ",
"수량": "벌목부수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{
"종류": "재료",
"요소": { "구분": "기계·설비", "상세구분": "기계류", "검색어": "동력체인톱" },
"이름": "체인톱 손료",
"단위": "대",
"수량": "벌목부수 * EQ000620.손료계수",
"비목": "경비",
"출처": "산림품셈 2-2-1"
}
],
"덧줄": [
{
"이름": "잡품",
"식": "재료비 * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"식": "줄.'휘발유(체인톱)' * 찾기(QF000001, 기계명='체인톱', 재료명='보통휘발유(주연료)').잡품 / 100",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주⑤"
},
{
"이름": "체인오일",
"식": "벌목부수 * 찾기(QF000001, 기계명='체인톱', 재료명='체인오일(일반오일)').주연료 * 체인오일단가",
"비목": "재료비",
"출처": "산림품셈 2-1-1 주④"
},
{ "이름": "체인톱 손료", "식": "벌목부수 * EQ000620.손료계수 * 체인톱가격", "비목": "경비", "출처": "산림품셈 2-2-1" }
}
],
"끝수": null
},
+32 -13
View File
@@ -98,15 +98,15 @@ def test_자동값으로_로직_전부_멈춤_0() -> None:
def test_원_단위_입력은_자동값_null(client) -> None:
got = _auto(client, "GF000056") # 체인톱가격(원) · 체인오일단가(원/ℓ)
assert got["체인톱가격"] is None and got["체인오일단가"] is None
assert got["소작업로"] == 0 # 원 아닌 수는 그대로 0
got = _auto(client, "GF000051") # 예취기가격(원)
assert got["예취기가격"] is None
assert got["조림목본수"] == 0 # 원 아닌 수는 그대로 0
def test_빈_입력을_쓰는_줄만_비움(client) -> None:
got = _calc(client, "GF000056")
got = _calc(client, "GF000051")
blank = {one["이름"]: one for one in got["lines"] if one.get("까닭")}
assert set(blank) == {"체인오일", "체인톱 손료"}
assert set(blank) == {"예취기 손료"}
for one in blank.values():
assert one["단가"] is None and one["금액"] is None and "비어 있음" in one["까닭"]
rest = sum(Decimal(str(one["금액"])) for one in got["lines"] if one["금액"] is not None)
@@ -115,16 +115,35 @@ def test_빈_입력을_쓰는_줄만_비움(client) -> None:
def test_빠진_원_입력도_null_처럼_줄만_비움(client) -> None:
given = {
k: v
for k, v in _auto(client, "GF000056").items()
if k not in ("체인톱가격", "체인오일단가")
}
given = {k: v for k, v in _auto(client, "GF000051").items() if k != "예취기가격"}
for path in ("calc", "text"):
got = client.post(f"/api/m01/{path}", json={"key": "GF000056", "inputs": given}).json()
got = client.post(f"/api/m01/{path}", json={"key": "GF000051", "inputs": given}).json()
assert got["ok"] is True, (path, got)
lines = client.post("/api/m01/calc", json={"key": "GF000056", "inputs": given}).json()["lines"]
assert {o["이름"] for o in lines if o.get("까닭")} == {"체인오일", "체인톱 손료"}
lines = client.post("/api/m01/calc", json={"key": "GF000051", "inputs": given}).json()["lines"]
assert {o["이름"] for o in lines if o.get("까닭")} == {"예취기 손료"}
@pytest.mark.parametrize("key", ["GF000002", "GF000056", "GF000076"])
def test_체인톱_가격_오일은_자재품목(client, key) -> None:
"""44-5 — 체인톱 가격 = 「동력체인톱」(기본 첫 규격) · 체인오일 = 「작동유」 · 손 입력 없음."""
assert not {"체인톱가격", "체인오일단가"} & set(_auto(client, key))
lines = {o["이름"]: o for o in _calc(client, key)["lines"]}
assert lines["체인톱 손료"]["품목"]["키"] == "MT016587" # 400mm · 530,000원
assert lines["체인톱 손료"]["단가"] == 530000 and "경비" in lines["체인톱 손료"]["비목"]
assert lines["체인오일"]["품목"]["키"] == "MT000583" # 기어작동유 · 4,612원/ℓ
assert lines["체인오일"]["단가"] == 4612
gas = Decimal(str(lines["휘발유(체인톱)"]["금액"]))
assert abs(Decimal(str(lines["잡품"]["금액"])) - gas * Decimal("0.4")) < Decimal(
"0.001"
) # 잡품 = 휘발유값 40% 그대로
def test_체인톱_규격은_고를_수_있음(client) -> None:
given = _auto(client, "GF000056")
body = {"key": "GF000056", "inputs": given, "고름": {"4": "MT016588"}} # 4 = 체인톱 손료 줄
got = client.post("/api/m01/calc", json=body).json()
saw = next(o for o in got["lines"] if o["이름"] == "체인톱 손료")
assert saw["품목"]["키"] == "MT016588" and saw["단가"] == 900000
def test_표_찾기_실패는_그_줄만_비우고_까닭은_날글_없이(client) -> None:
@@ -247,8 +247,6 @@ def test_방제_벌목조재_감_할증(whole):
"경급": 6,
"소군락": "소군락 모두베기",
"방제수종": "잣나무",
"체인톱가격": 1200000,
"체인오일단가": 5000,
"지역": "전국평균",
}
got = mf.run(whole, "GF000076", given)
@@ -0,0 +1,57 @@
// 44-5 체인톱 가격 · 오일 = 자재품목 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎(async 식 · JSON 돌려줌).
// 시작 화면 = M01 마스터 데이터(#/m01-master-data) · 좌측 「단가산출 로직」 목록(1쪽에 GF000002 보임).
// 하는 일 = GF000002 작업로 설치 상세 → 시험 계산 입력 · 재료 줄 품목 읽음 → [계산] → 소계 · 계 읽음(저장 안 함).
// 기대 = inputs 에 체인톱가격 · 체인오일단가 없음 · mats = 3:MT000583(기어작동유) · 4:MT016587(동력체인톱 400mm) ·
// 재료 소계 24,293 · 경비 소계 4,452(체인톱 손료 0.0084 × 530,000) · 계 454,056.
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const until = async (fn, ms = 8000) => {
for (let t = 0; t < ms; t += 150) {
const got = fn();
if (got) return got;
await wait(150);
}
return null;
};
const back = [...document.querySelectorAll("button,a")].find((b) =>
b.textContent.includes("로직 목록"),
);
back?.click();
const tr = await until(() =>
[...document.querySelectorAll("tr")].find((t) => t.textContent.startsWith("GF000002")),
);
if (!tr) return JSON.stringify({ error: "목록에 GF000002 없음" });
tr.click();
const calc = await until(() =>
document.querySelector('[data-mat-idx="4"]')
? [...document.querySelectorAll("button")].find((b) => b.textContent.trim() === "계산")
: null,
);
if (!calc) return JSON.stringify({ error: "시험 계산 재료 줄 없음" });
const box = calc.closest("section,div");
const labels = [...document.querySelectorAll(".m01-logic__field > span:first-child")].map((s) =>
s.textContent.trim(),
);
const mats = [...document.querySelectorAll("[data-mat-idx]")].map(
(s) => `${s.dataset.matIdx}:${s.dataset.ref}:${s.textContent.trim()}`,
);
calc.click();
const sub = (name) =>
[...document.querySelectorAll("tr")]
.find((t) => t.textContent.trim().startsWith(name))
?.innerText.replace(/\s+/g, " ")
.trim();
const done = await until(() => (sub("경비 소계") ?? "").match(/\d/));
const total = [...document.querySelectorAll("*")]
.filter((x) => x.children.length === 0 && /^계 [\d,]+$/.test(x.textContent.trim()))
.map((x) => x.textContent.trim());
return JSON.stringify({
ok: !!done && !!box,
noHandInput: !labels.some((l) => /체인톱가격|체인오일단가/.test(l)),
labels,
mats,
material: sub("재료 소계"),
expense: sub("경비 소계"),
total: total[0],
});
})();