Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
* (821-3) 값 줄만 바뀜(M01 단가 · 내 기계 고침) = 「새 값 있음」(같은 자리 · 같은 꼴) · 지운 키(M01 · 내 기계) = 「지워짐 n」 ·
|
||||
* [단가 갱신] 확인 창 = 바뀐 자원 줄(이름 · 바뀐 칸 전 → 후 · 지워짐 = 마지막 값 유지)
|
||||
* (822-1) 값 사본에 없는 셈 줄(옛 저장본 빈틈) = 「사본 없는 줄 n」(지금 값으로 셈 · 저장하면 굳음)
|
||||
* (836-2) 표지 `자동`(프로젝트 층 밖 = M01 자동 따라옴) = 단추 · 「새 값」 · 「사본 없는 줄」 안 그림(지워짐만)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
@@ -18,6 +19,7 @@ import type { FormulaDoc } from "../common_util/common_util_formula_doc";
|
||||
import type { Basis } from "../common_util/common_util_formula_doc_import";
|
||||
import { swappedCount } from "../common_util/common_util_line_stack_pick";
|
||||
import { ft } from "./formula_doc_text";
|
||||
import type { RenewNote } from "./formula_doc_renew";
|
||||
|
||||
export type CopyJob = "단가갱신" | "재생성";
|
||||
|
||||
@@ -32,6 +34,10 @@ export interface CopyMark {
|
||||
따로?: boolean;
|
||||
/** 가져온 식(엑셀 불러오기 · 807-1) 단가기준 — 엑셀 = 엑셀 값 기억 · 우리 = 연결 값 */
|
||||
단가기준?: Basis;
|
||||
/** M01 자동 따라옴(836-2 · 프로젝트 층 밖) — [단가 갱신] · [재생성] · 「새 값」 · 「사본 없는 줄」 없음 */
|
||||
자동?: boolean;
|
||||
/** 열 때 알림(836-2) — M01 에서 바뀐 항목의 지운 고침 */
|
||||
갱신?: RenewNote[];
|
||||
}
|
||||
|
||||
export interface CopyOptions {
|
||||
@@ -58,7 +64,7 @@ export function isStale(m: CopyMark | undefined): boolean {
|
||||
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
|
||||
const out: HTMLElement[] = [];
|
||||
const doc = !!m?.M01새판 || !!m?.문서새판?.length;
|
||||
if (doc || m?.단가새값) {
|
||||
if ((doc || m?.단가새값) && !m?.자동) {
|
||||
const b = createBadge(ft(doc ? "New_M01" : "New_Value"), "danger");
|
||||
b.dataset.copyStale = doc ? "doc" : "value";
|
||||
out.push(b);
|
||||
@@ -69,7 +75,7 @@ export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[
|
||||
b.dataset.copyGone = m.지운키.join(",");
|
||||
out.push(b);
|
||||
}
|
||||
if (m?.사본없음) {
|
||||
if (m?.사본없음 && !m.자동) {
|
||||
const b = createBadge(`${ft("Gap")} ${m.사본없음}`, "warning");
|
||||
b.title = ft("Gap_Note");
|
||||
b.dataset.copyGap = String(m.사본없음);
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/* =============================================================================
|
||||
* formula_doc_renew.ts
|
||||
* M01 자동 따라옴 알림(PLAN 836-2 · 요구 854) — M02 단가산출(프로젝트 층 밖)을 열 때
|
||||
* M01 에서 바뀐 항목에 얹힌 내 고침을 지우고 다시 복사한 것을 한 번 보임(서버 `M02_Sk_AutoRenew.notes`)
|
||||
* 표 = 항목 · M01 전 → 후 · 지워진 내 고침 · [확인] = 저장(다시 안 뜸) · [나중에] = 다음에 열면 또 뜸
|
||||
* 고침 없는 항목은 조용히 따라옴(알림 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { askConfirm, el } from "@ui/ui_template_elements";
|
||||
import { createTable } from "@ui/ui_template_table";
|
||||
import { ft } from "./formula_doc_text";
|
||||
|
||||
/** 서버 알림 한 줄 — 갈래 = 문서(바탕 수식) · 부름(굳힌 부른 문서) · 기계(기계 덮음) */
|
||||
export interface RenewNote {
|
||||
키: string;
|
||||
이름?: string;
|
||||
갈래?: string;
|
||||
M01?: string[];
|
||||
내고침?: string[];
|
||||
}
|
||||
|
||||
/** 칸 안 여러 줄 — 줄마다 한 덩이 */
|
||||
const lines = (xs: string[] | undefined): HTMLElement =>
|
||||
el("div", { children: (xs ?? []).map((t) => el("div", { text: t })) });
|
||||
|
||||
/** 알림 창 — 확인이면 true(부르는 쪽이 저장) */
|
||||
export function askRenew(notes: RenewNote[]): Promise<boolean> {
|
||||
const table = createTable(
|
||||
[
|
||||
{ label: ft("Renew_Item"), width: "28%" },
|
||||
{ label: ft("Renew_M01") },
|
||||
{ label: ft("Renew_Mine") },
|
||||
],
|
||||
notes.map((n) => [lines([n.이름 || n.키, n.키]), lines(n.M01), lines(n.내고침)]),
|
||||
{ density: "data" },
|
||||
);
|
||||
table.dataset.renewNotes = String(notes.length);
|
||||
return askConfirm({
|
||||
title: ft("Renew_Title"),
|
||||
message: ft("Renew_Ask"),
|
||||
okLabel: ft("Renew_Ok"),
|
||||
cancelLabel: ft("Renew_Later"),
|
||||
detail: table,
|
||||
});
|
||||
}
|
||||
@@ -78,6 +78,16 @@ const TEXT = {
|
||||
Changed: ["바뀐 자원", "Changed resources"],
|
||||
Before_After: ["계", "Sum"],
|
||||
Job_Done: ["적용함", "Applied"],
|
||||
Renew_Title: ["M01 이 바뀌어 다시 복사함", "Re-copied from updated M01"],
|
||||
Renew_Ask: [
|
||||
"M01 에서 바뀐 항목에 있던 내 고침을 지우고 M01 에서 다시 복사함 — [확인] 하면 저장(다시 안 뜸)",
|
||||
"Your edits on items changed in M01 were removed and re-copied — OK saves (no repeat)",
|
||||
],
|
||||
Renew_Item: ["항목", "Item"],
|
||||
Renew_M01: ["M01 전 → 후", "M01 before → after"],
|
||||
Renew_Mine: ["지워진 내 고침", "Removed edits"],
|
||||
Renew_Ok: ["확인", "OK"],
|
||||
Renew_Later: ["나중에", "Later"],
|
||||
Basis_Excel: ["엑셀 단가 그대로", "Excel prices"],
|
||||
Basis_Ours: ["우리 단가로", "Our prices"],
|
||||
Dirty_First: [
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
swapBadge,
|
||||
} from "./formula_doc_copy";
|
||||
import { docFoot } from "./formula_doc_foot";
|
||||
import { askRenew } from "./formula_doc_renew";
|
||||
import { buildOptions } from "./formula_doc_options";
|
||||
import { ft } from "./formula_doc_text";
|
||||
import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy";
|
||||
@@ -316,7 +317,7 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
const busy = dirty();
|
||||
const tools = [
|
||||
...(mark?.단가기준 && opt.page ? [basisPick(mark.단가기준)] : []), // 모달 = 머리 판 단추(793-3b)
|
||||
...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : []),
|
||||
...(cp && edit() && key && !mark?.자동 ? copyTools(true, (job) => void runJob(job)) : []), // 836-2 자동 = 없음
|
||||
...(edit() && key && !opt.page
|
||||
? [
|
||||
createButton({
|
||||
@@ -548,6 +549,9 @@ export function createStackScreen(opt: StackScreenOptions) {
|
||||
recalc();
|
||||
draw();
|
||||
opt.page?.(); // 캐시에서 이어 받은 고침 — 페이지 [저장] · [되돌림] 켬
|
||||
const notes = (mark as CopyMark | undefined)?.갱신; // 836-2 M01 자동 따라옴 알림(take 가 채움)
|
||||
if (cp && edit() && !dirty() && notes?.length)
|
||||
void askRenew(notes).then((ok) => ok && save({ 할일: "갱신확인", 판: version }, ""));
|
||||
} catch (e) {
|
||||
if (mine === seq)
|
||||
head.replaceChildren(
|
||||
|
||||
@@ -27,6 +27,7 @@ from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
from M02_MasterTemplete import M02_MyMachine_Draft as draft
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
KEY = dc.KEY
|
||||
OLD_FIELDS = (
|
||||
@@ -119,6 +120,7 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None:
|
||||
got = sk.get(KEY) if dc.has(sk) else seed(sk)
|
||||
if got is None:
|
||||
return None
|
||||
got = renew.form(sk, got) # M01 자동 따라옴(836-2)
|
||||
if got.get("따로") or ctx is None:
|
||||
return got
|
||||
if dim.is_imported(sk): # 가져온 식 — 엑셀 단가 기준 줄에서 지음(우리 = 한 번 더 돎)
|
||||
@@ -129,10 +131,11 @@ def copy_of(sk: dict, ctx: tuple | None) -> dict | None:
|
||||
root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)}
|
||||
return {**got, "문서": root, "부름": {}}
|
||||
try:
|
||||
root = derive(sk, ctx)
|
||||
root = renew.carry(got, derive(sk, ctx)) # 맡긴 기계 덮음(836-2)
|
||||
except dc._ERR as e:
|
||||
root = {**dc.md.blank(str(sk.get("키"))), "_틀림": str(e)}
|
||||
values = dc.values_of(sk[skc.KEY]) if skc.has(sk) else dc.values_of(got) # 서버가 정한 값
|
||||
values = renew.rows(values) if renew.is_sk(sk) else values # 836-2
|
||||
whole = dc.over(values)
|
||||
calls = dc.closure(whole, dc.calls(root), got.get("부름"))
|
||||
base = str((sk.get("바탕") or {}).get("로직") or "")
|
||||
@@ -191,6 +194,7 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
|
||||
return {**out, KEY: norm(mine["사본"])}
|
||||
file = stored(out.get("키")) or {}
|
||||
have = file.get(KEY) if dc.has(file) else (raw.get(KEY) if dc.has(raw) else None)
|
||||
have = renew.form(out, have) if have else have # 836-2 — 바뀐 항목 고침 지움 · 저장 때 굳음
|
||||
if have and have.get("따로") and job != skc.REGEN:
|
||||
if file and old_fp(file) != old_fp(out):
|
||||
raise sd.DocError(
|
||||
@@ -210,6 +214,8 @@ def settle(out: dict, whole: Any, job: Any, raw: Any) -> dict:
|
||||
calc.run(out, whole, catch=box)
|
||||
calls = {} if job == skc.REGEN else (have or {}).get("부름") or {}
|
||||
first = {**(dc.values_of(out[skc.KEY]) if skc.has(out) else {}), "부름": calls}
|
||||
if job != skc.REGEN and (have or {}).get("끊음"): # 안 바뀐 항목 고침 = 그대로(836-2)
|
||||
first["끊음"] = have["끊음"]
|
||||
copy = copy_of({**out, KEY: first}, box[-1] if box else None)
|
||||
if (
|
||||
copy and skc.has(out) and skc.READS.get() is None and (keys := gap.gaps(copy))
|
||||
|
||||
@@ -26,6 +26,9 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_MyMachine_Draft as draft
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
import master_doc_cover as mcv # noqa: E402 — 기계 덮음(836-1 · scripts 길 = dc)
|
||||
|
||||
MAX = 2_000_000 # 문서 글 크기(바이트) 상한
|
||||
JOBS = (skc.PRICE, skc.REGEN)
|
||||
@@ -78,7 +81,7 @@ def answer(copy: dict, sk: dict) -> dict[str, Any]:
|
||||
"""{문서, 부름, 끊음, 셈, 사본 표지, 연결 기록} — 글 · 수는 JSON 꼴 · 가져온 식 표지 = 단가기준."""
|
||||
got = dsk.run(sk, copy)
|
||||
head = {k: copy.get(k) for k in ("시점", "단가시점")}
|
||||
mark = {**head, "따로": bool(copy.get("따로"))}
|
||||
mark = {**head, "따로": bool(copy.get("따로")), "자동": renew.on()} # 836-2 M01 자동 따라옴
|
||||
mark |= (
|
||||
{"단가기준": dim.basis_of(sk)}
|
||||
if dim.is_imported(sk)
|
||||
@@ -98,7 +101,9 @@ def answer(copy: dict, sk: dict) -> dict[str, Any]:
|
||||
def read(key: str) -> dict[str, Any]:
|
||||
"""읽기 — {문서, 부름, 끊음, 셈, 사본, 판}."""
|
||||
got = _file(key)
|
||||
return {**answer(current(got["문서"]), got["문서"]), "판": got["판"]}
|
||||
out = {**answer(current(got["문서"]), got["문서"]), "판": got["판"]}
|
||||
out["사본"]["갱신"] = renew.notes(got["문서"]) # 836-2 열 때 알림(지울 고침)
|
||||
return out
|
||||
|
||||
|
||||
def read_memory(sk: Any) -> dict[str, Any]:
|
||||
@@ -148,10 +153,23 @@ def edited(copy: dict, body: dict, key: str, sk: dict | None = None) -> dict:
|
||||
*dc.calls(out["문서"]),
|
||||
*(c for d in out.get("끊음", {}).values() for c in dc.calls(d)),
|
||||
]
|
||||
try: # 기계 덮음(836-1) — 몸 검사 · 원본과 같은 칸 · 안 부르는 기계 뺌(가져온 식 = 검사만)
|
||||
if dim.is_imported(sk):
|
||||
got = mcv.clean(out["문서"].get(mcv.COVER))
|
||||
rest = {k: v for k, v in out["문서"].items() if k != mcv.COVER}
|
||||
out["문서"] = {**rest, mcv.COVER: got} if got else rest
|
||||
except ValueError as e:
|
||||
raise _bad(str(e)) from e
|
||||
if not dim.is_imported(sk):
|
||||
out["부름"] = dc.closure(dc.over(out), calls, copy.get("부름"))
|
||||
try:
|
||||
out["문서"] = mcv.prune_doc(out["문서"], dc.ml.Links(dc.over(out), dc.docs_of(out)))
|
||||
except ValueError as e:
|
||||
raise _bad(str(e)) from e
|
||||
out = gap.fill(out) # 셈이 읽은 줄 가운데 사본 없는 것 = 지금 값으로 박음(822-1)
|
||||
out = draft.renew(out) # 기계 창 초안으로 만든 · 고친 내 기계 = 새 값(827-1)
|
||||
if not copy.get("따로"): # 고침 판정 원본(836-2) = 따로가 되기 전 문서
|
||||
out[renew.FIRST] = copy["문서"]
|
||||
out["따로"] = True
|
||||
return out
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const TEXT = {
|
||||
Copied: "「{value}」 값을 복사 — 고친 뒤 [저장]",
|
||||
Own_Value: "개인 값",
|
||||
Own_Note:
|
||||
"개인 값 = 이 기계 고유 값(가격 · 손료계수 · 연료 ℓ · 잡품 % · 조종원 수) · 단가(노임 · 유가)는 M01 값 · 단가산출엔 만들 때 복사 → 고친 뒤 「새 값」 → [단가 갱신]",
|
||||
"개인 값 = 이 기계 고유 값(가격 · 손료계수 · 연료 ℓ · 잡품 % · 조종원 수) · 단가(노임 · 유가)는 M01 값 · 단가산출(프로젝트 밖)은 고치면 저절로 따라옴",
|
||||
Server_Hourly: "서버 셈 시간당",
|
||||
Hourly_None: "시간당 못 셈",
|
||||
Uses: "쓰는 단가산출",
|
||||
@@ -59,7 +59,7 @@ const TEXT = {
|
||||
Win_M01: "M01 기계",
|
||||
Win_Copy: "이 단가산출에 복사된 값",
|
||||
Win_Live: "원본 지금 값",
|
||||
Win_Newer: "원본에 새 값 있음 — [단가 갱신] 으로 반영",
|
||||
Win_Newer: "원본에 새 값 있음 — 프로젝트 사본은 [단가 갱신] 으로 반영",
|
||||
Win_Swap: "이 기계로 바꾸기",
|
||||
Win_Edit: "고쳐 쓰기",
|
||||
Win_Blank: "빈 양식에서 새 기계",
|
||||
@@ -67,7 +67,7 @@ const TEXT = {
|
||||
Win_Cancel: "취소",
|
||||
Win_Edit_Note:
|
||||
"[확인] = 이 단가산출에 초안으로 끼움 · 단가산출 [저장] 때 내 기계로 저장 · 저장 안 하고 나가면 사라짐",
|
||||
Win_Uses: "이 기계를 쓰는 다른 단가산출 {value} 건 — 저장하면 그쪽에 「새 값」 배지",
|
||||
Win_Uses: "이 기계를 쓰는 다른 단가산출 {value} 건 — 저장하면 그쪽도 새 값으로 셈",
|
||||
Win_Hint: "왼쪽에서 기계를 고르면 값이 보임",
|
||||
Win_View: "조회",
|
||||
Why_M01: "기계 값은 M01 기계 상세에서",
|
||||
|
||||
@@ -125,11 +125,18 @@ def _forget(whole: Any) -> None:
|
||||
_LIVE[0] = whole
|
||||
|
||||
|
||||
def _auto(doc: Any, copy: dict) -> dict:
|
||||
"""SK 사본 = 프로젝트 층 밖이면 지금 M01 줄(836-2 · `M02_Sk_AutoRenew`)."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
return renew.rows(copy) if renew.is_sk(doc) else copy
|
||||
|
||||
|
||||
def over(doc: Any, whole: Any) -> Any:
|
||||
"""사본 있는 문서 = 사본 색인 · 없으면 `whole` 그대로(옛 길)."""
|
||||
if not has(doc):
|
||||
return whole
|
||||
copy = doc[KEY]
|
||||
copy = _auto(doc, doc[KEY])
|
||||
tag = (copy.get("판"), copy.get("단가판"))
|
||||
if isinstance(whole, Copy) and whole._tag == tag:
|
||||
return whole
|
||||
@@ -290,6 +297,8 @@ def settle(doc: Any, save: bool = False) -> Any:
|
||||
copy = refresh(copy, whole_())[0]
|
||||
if copy and any(isinstance(x, dict) and x.get("다시잇기") for x in out.get("더한줄") or []):
|
||||
copy = rejoin(copy, out["더한줄"])
|
||||
if copy:
|
||||
copy = _auto(doc, copy) # M01 자동 따라옴(836-2) — 저장 때 굳음
|
||||
if save and copy and READS.get() is None: # 읽은 줄만(826-3) = 저장 관문이 더함
|
||||
copy = grow(copy, stamp(out))
|
||||
out = _formula_job(out, job)
|
||||
@@ -305,7 +314,8 @@ def _formula_job(out: dict, job: Any) -> dict:
|
||||
if got is None or job not in (PRICE, REGEN):
|
||||
return out
|
||||
if job == REGEN:
|
||||
got = {k: v for k, v in got.items() if k not in ("문서", "끊음", "따로")} | {"부름": {}}
|
||||
drop = ("문서", "끊음", "따로", "처음", "기계덮음")
|
||||
got = {k: v for k, v in got.items() if k not in drop} | {"부름": {}}
|
||||
elif got.get("따로"):
|
||||
got = dc.refresh(got)[0]
|
||||
return {**out, dc.KEY: got}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""M02 단가산출(SK) M01 자동 따라옴(PLAN 836-2 · 요구 854) — 서버 단독.
|
||||
|
||||
프로젝트 층 밖 SK(마스터 · 회사 · 개인) = 셈 · 열기 · 목록 · 일위대가 펼침이 모두
|
||||
사본 줄 = 지금 M01(+ 층 내 기계) 줄(`rows` · 지운 키 = 사본 마지막 줄) ·
|
||||
부름 문서 = 지금 M01 수식 문서(`form`)로 봄 — 묻지 않음 · [단가 갱신] · [재생성] 없음.
|
||||
파일(작업본)은 [저장] · 알림 [확인](= 저장) 때만 새 것으로 씀 — 읽기 길은 안 씀 ·
|
||||
저장 파일의 사본 = 마지막 저장 때 받은 M01(= 고침 판정의 「복사 당시 원본」).
|
||||
고침 판정 = 내용 대조(표지 아님 · 바꿨다 되돌림 = 안 고침) — 셈에 닿는 칸(`LOGIC`)만 견줌:
|
||||
끊음[키] 대 부름[키](복사 당시) · 따로 문서 대 `처음`(따로가 된 때의 문서 · 없으면 고친 것).
|
||||
M01 에서 바뀐 항목에 고침이 얹혀 있으면 그 고침을 지우고 다시 복사 + 알림 `갱신` 한 줄
|
||||
{키, 이름, 갈래, M01 [전 → 후], 내고침 [지워진 값]} · 고침 없는 항목 = 조용히 따라옴 ·
|
||||
안 바뀐 항목의 고침 = 그대로. 따로 문서를 지우면 파생(옛 칸 + 새 M01)으로 돌아감.
|
||||
가져온 식 SK = 값 줄만 따라옴(가져온 줄 문서 · 식 안 건드림 · 단가기준 「엑셀」 은 엑셀 값 그대로).
|
||||
프로젝트 층 · B09 사본 = 그대로(사용자가 고르는 [단가 갱신] · [재생성]).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy as cp
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
|
||||
import master_doc_cover as mcv # noqa: E402 — 기계 덮음(836-1 · scripts 길 = dc)
|
||||
|
||||
COVER = mcv.COVER # 문서 칸 기계덮음(836-1) — 따로 문서를 다시 복사할 때 `수식` 머리에 맡겨 둠
|
||||
FIRST = "처음" # `수식` 칸 — 따로가 된 때의 문서(고침 판정 원본)
|
||||
LOGIC = (
|
||||
"변수",
|
||||
"식",
|
||||
"금액",
|
||||
"끝수",
|
||||
"결과단위",
|
||||
) # 셈에 닿는 칸 — 쌓기(차림) · 이름 · 판은 안 견줌
|
||||
MAX_LINES = 8
|
||||
_MEMO: dict[tuple, dict] = {}
|
||||
_LIVE: list[Any] = [None]
|
||||
|
||||
|
||||
def on() -> bool:
|
||||
"""자동 따라옴 — 프로젝트 층 밖."""
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
return refs.here()[0] != "project"
|
||||
|
||||
|
||||
def is_sk(doc: Any) -> bool:
|
||||
return isinstance(doc, dict) and doc.get("종류") == sd.TITLE
|
||||
|
||||
|
||||
# ── 값 줄 ──────────────────────────────────────────────────────────────
|
||||
def _fresh(row: dict, live: Any) -> dict:
|
||||
key = str(row.get("키"))
|
||||
got = live.index.get(key[:2], {}).get(key)
|
||||
if got is None:
|
||||
return row # M01 에서 지운 키 = 마지막 값
|
||||
new = prices.to_json(got)
|
||||
return row if skc._fp(new) == skc._fp(row) else new
|
||||
|
||||
|
||||
def rows(copy: Any) -> Any:
|
||||
"""값 사본(`skc` 꼴) → 줄마다 지금 M01(+ 층 내 기계) · 바뀐 몫만 판 다시 · 프로젝트 층 = 그대로."""
|
||||
if not (isinstance(copy, dict) and isinstance(copy.get("로직"), list)) or not on():
|
||||
return copy
|
||||
live = skc.whole_()
|
||||
if live is not _LIVE[0]:
|
||||
_MEMO.clear()
|
||||
_LIVE[0] = live
|
||||
tag = (copy.get("판"), copy.get("단가판"), len(copy["로직"]), len(copy.get("단가") or []))
|
||||
hit = _MEMO.get(tag)
|
||||
if hit is not None:
|
||||
return {**copy, **hit}
|
||||
got: dict = {}
|
||||
for part, mark in (("로직", "판"), ("단가", "단가판")):
|
||||
old = copy.get(part) or []
|
||||
new = [_fresh(r, live) if isinstance(r, dict) else r for r in old]
|
||||
if any(a is not b for a, b in zip(old, new)):
|
||||
got |= {part: new, mark: skc._fp(new)}
|
||||
if len(_MEMO) > 64: # ponytail: 통째 비움 · SK 가 많아 느려지면 LRU 로
|
||||
_MEMO.clear()
|
||||
_MEMO[tag] = got
|
||||
return {**copy, **got}
|
||||
|
||||
|
||||
# ── 수식 사본(부름 · 끊음 · 따로 문서) ──────────────────────────────────
|
||||
def _logic(doc: Any) -> str:
|
||||
from M02_MasterTemplete import M02_Doc_Sk as dsk
|
||||
|
||||
doc = doc if isinstance(doc, dict) else {}
|
||||
return dc.fp(dsk._strip({k: doc.get(k) for k in LOGIC}))
|
||||
|
||||
|
||||
def _say(v: dict) -> str:
|
||||
link = v.get("연결") or {}
|
||||
for slot in ("값", "식", "키"):
|
||||
if link.get(slot) not in (None, ""):
|
||||
return str(link[slot])
|
||||
return json.dumps(link, ensure_ascii=False, default=str)[:60]
|
||||
|
||||
|
||||
def _name(v: dict) -> str:
|
||||
return str(v.get("이름") or f"{v.get('기호') or ''}{v.get('아래') or ''}")
|
||||
|
||||
|
||||
def diff(a: Any, b: Any) -> list[str]:
|
||||
"""두 문서의 셈 칸 다른 곳 — 「이름 전 → 후」(변수 · 금액 줄) · 많으면 앞 몇 줄."""
|
||||
a, b = (x if isinstance(x, dict) else {} for x in (a, b))
|
||||
out: list[str] = []
|
||||
va = {dc.md.var_id(v): v for v in a.get("변수") or []}
|
||||
vb = {dc.md.var_id(v): v for v in b.get("변수") or []}
|
||||
for vid in [*va, *(k for k in vb if k not in va)]:
|
||||
x, y = va.get(vid), vb.get(vid)
|
||||
if x and y and _say(x) == _say(y):
|
||||
continue
|
||||
name = _name(y or x)
|
||||
out.append(f"{name} {_say(x) if x else '—'} → {_say(y) if y else '—'}")
|
||||
la, lb = a.get("금액") or [], b.get("금액") or []
|
||||
for n in range(max(len(la), len(lb))):
|
||||
x, y = (la[n] if n < len(la) else None), (lb[n] if n < len(lb) else None)
|
||||
if dc.fp(x) == dc.fp(y):
|
||||
continue
|
||||
name = str((y or x or {}).get("이름") or f"금액 {n + 1}")
|
||||
show = lambda z: " × ".join(str(z[k]) for k in ("수량", "단가") if z and z.get(k)) or "—" # noqa: E731
|
||||
out.append(f"{name} {show(x)} → {show(y)}")
|
||||
for slot in ("식", "끝수", "결과단위"):
|
||||
if dc.fp(a.get(slot)) != dc.fp(b.get(slot)):
|
||||
out.append(slot)
|
||||
return out[:MAX_LINES] + ([f"… {len(out) - MAX_LINES}"] if len(out) > MAX_LINES else [])
|
||||
|
||||
|
||||
def form(sk: Any, got: Any, notes: list | None = None) -> Any:
|
||||
"""수식 사본 → 지금 M01 으로 본 것(값 줄 · 부름) · M01 에서 바뀐 항목의 고침(끊음 · 따로 문서)은
|
||||
지우고 `notes` 에 한 줄 · 안 바뀐 항목 고침 · 프로젝트 층 = 그대로."""
|
||||
if not isinstance(got, dict) or not on() or not is_sk(sk):
|
||||
return got
|
||||
out = rows(got) if "로직" in got else dict(got)
|
||||
out = _covers(got, out, notes)
|
||||
if imp.is_imported(sk):
|
||||
return out # 가져온 식 = 값만(식 · 가져온 줄 그대로)
|
||||
live = dc.folder()
|
||||
calls = dict(out.get("부름") or {})
|
||||
changed = {k for k, d in calls.items() if k in live and dc.fp(d) != dc.fp(live[k])}
|
||||
if not changed and not out.get("따로"):
|
||||
return out
|
||||
for k in changed:
|
||||
calls[k] = cp.deepcopy(live[k])
|
||||
cut = dict(out.get("끊음") or {})
|
||||
for k in sorted(changed & set(cut)):
|
||||
mine, was = cut.pop(k), (got.get("부름") or {}).get(k)
|
||||
if _logic(mine) != _logic(was) and notes is not None:
|
||||
notes.append(_note(k, was, live[k], diff(was, mine), "부름"))
|
||||
out |= {"부름": calls, "끊음": cut}
|
||||
base = out.get("원본") or {}
|
||||
key = str(base.get("키") or "")
|
||||
if out.get("따로") and key in live and dc.fp(live[key]) != base.get("판"):
|
||||
first = out.get(FIRST)
|
||||
mine = out.get("문서") or {}
|
||||
if first is None or _logic(mine) != _logic(first):
|
||||
mark = (
|
||||
diff(first, mine)
|
||||
if first is not None
|
||||
else ["수식 문서 고침(옛 저장본 — 원본 없음)"]
|
||||
)
|
||||
if notes is not None:
|
||||
notes.append(_note(key, first, live[key], mark, "문서", mine.get("이름")))
|
||||
keep = {COVER: mine[COVER]} if mine.get(COVER) else {} # 기계 덮음 = 다른 항목(그대로)
|
||||
out = {k: v for k, v in out.items() if k not in ("따로", "문서", FIRST, COVER)} | keep
|
||||
if changed and "문서" in out: # 새 문서가 부르는 키 = 닫힌 묶음
|
||||
keys = [*dc.calls(out["문서"]), *(c for d in cut.values() for c in dc.calls(d))]
|
||||
out["부름"] = dc.closure(dc.over(out), keys, calls)
|
||||
return out
|
||||
|
||||
|
||||
# ── 기계 덮음(836-1) — 그 기계 줄이 M01 에서 바뀌면 덮음 지움 ─────────────
|
||||
def _eq_rows(copy: dict) -> dict[str, dict]:
|
||||
"""사본의 기계 줄 {원문번호 · 키: 줄}."""
|
||||
out: dict[str, dict] = {}
|
||||
for r in [*(copy.get("로직") or []), *(copy.get("단가") or [])]:
|
||||
if isinstance(r, dict) and str(r.get("키"))[:2] == "EQ":
|
||||
out[str(r.get("키"))] = r
|
||||
if r.get("원문번호"):
|
||||
out[str(r["원문번호"])] = r
|
||||
return out
|
||||
|
||||
|
||||
def _mine(entry: dict) -> list[str]:
|
||||
vals = [f"{k} {v}" for k, v in (entry.get("값") or {}).items()]
|
||||
crew = [f"조종원 {x.get('키')} {x.get('인')}인" for x in entry.get("조종원") or []]
|
||||
misc = [
|
||||
f"잡품 {x.get('이름') or x.get('키') or x.get('줄')} {x.get('율')}%"
|
||||
for x in entry.get("잡품") or []
|
||||
]
|
||||
return [*vals, *crew, *misc] or ["덮음"]
|
||||
|
||||
|
||||
def _row_diff(a: dict, b: dict) -> list[str]:
|
||||
va, vb = a.get("값") or {}, b.get("값") or {}
|
||||
if not isinstance(va, dict) or not isinstance(vb, dict):
|
||||
return [f"{a.get('값')} → {b.get('값')}"]
|
||||
keys = [*va, *(k for k in vb if k not in va)]
|
||||
return [f"{k} {va.get(k, '—')} → {vb.get(k, '—')}" for k in keys if va.get(k) != vb.get(k)]
|
||||
|
||||
|
||||
def _covers(got: dict, out: dict, notes: list | None) -> dict:
|
||||
"""문서(따로) · 맡긴 기계 덮음 가운데 그 기계 줄이 M01 에서 바뀐 것 = 지움 + 알림."""
|
||||
doc = out.get("문서") if isinstance(out.get("문서"), dict) else None
|
||||
cover = dict(mcv.cover_of(doc) if doc is not None else out.get(COVER) or {})
|
||||
if not cover:
|
||||
return out
|
||||
was, now = _eq_rows(got), _eq_rows(out)
|
||||
for text in list(cover):
|
||||
a, b = was.get(text), now.get(text)
|
||||
if a is None or b is None or skc._fp(a) == skc._fp(b):
|
||||
continue
|
||||
entry = cover.pop(text)
|
||||
if notes is not None:
|
||||
name = f"{b.get('이름') or ''} {b.get('규격') or ''}".strip() or text
|
||||
notes.append(
|
||||
{
|
||||
"키": str(b.get("키")),
|
||||
"이름": name,
|
||||
"갈래": "기계",
|
||||
"M01": _row_diff(a, b) or ["바뀜"],
|
||||
"내고침": _mine(entry),
|
||||
} # fmt: skip
|
||||
)
|
||||
if doc is not None:
|
||||
rest = {k: v for k, v in doc.items() if k != COVER}
|
||||
return {**out, "문서": {**rest, COVER: cover} if cover else rest}
|
||||
return {k: v for k, v in out.items() if k != COVER} | ({COVER: cover} if cover else {})
|
||||
|
||||
|
||||
def carry(got: dict, root: dict) -> dict:
|
||||
"""파생 문서에 맡긴 기계 덮음을 다시 얹음(따로 문서를 다시 복사한 뒤)."""
|
||||
return {**root, COVER: got[COVER]} if isinstance(got.get(COVER), dict) and got[COVER] else root
|
||||
|
||||
|
||||
def _note(key: str, was: Any, now: dict, mine: list[str], kind: str, name: Any = None) -> dict:
|
||||
return {
|
||||
"키": key,
|
||||
"이름": str(name or now.get("이름") or key),
|
||||
"갈래": kind,
|
||||
"M01": diff(was, now) or ["바뀜"],
|
||||
"내고침": mine,
|
||||
}
|
||||
|
||||
|
||||
def notes(sk: Any) -> list[dict]:
|
||||
"""열 때 알림 — 이 SK 저장본에서 지울 고침(없으면 []) · 가져온 식 · 프로젝트 층 = []."""
|
||||
if not is_sk(sk) or not dc.has(sk) or imp.is_imported(sk):
|
||||
return []
|
||||
out: list[dict] = []
|
||||
form(sk, sk[dc.KEY], out)
|
||||
return json.loads(json.dumps(dc.ml._norm(out), ensure_ascii=False, default=str))
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
type Node,
|
||||
type Rule,
|
||||
} from "./common_util_formula_doc";
|
||||
import { coverDoc, coverOf as machineCover, type Cover } from "./common_util_formula_doc_cover";
|
||||
|
||||
const EPS = 1e-7; // B09_Estimation_Calc_Base EPS 와 같음
|
||||
const DEEP = 20;
|
||||
@@ -137,6 +138,7 @@ class Run {
|
||||
readonly given: Record<string, unknown>,
|
||||
readonly stack: string[],
|
||||
readonly memo: Memo,
|
||||
readonly cover: Cover, // 기계 덮음(836-1) — 맨 위 문서 칸 · 부름에 얹음
|
||||
) {
|
||||
for (const v of doc.변수 ?? []) this.vars.set(varId(v), v);
|
||||
for (const l of doc.식 ?? []) this.lines.set(String(l.기호 ?? ""), l.식);
|
||||
@@ -364,8 +366,13 @@ class Run {
|
||||
.sort()
|
||||
.map((k) => `${k}=${plainText(given[k])}`)
|
||||
.join("|")}`;
|
||||
if (!this.memo.has(memoKey))
|
||||
this.memo.set(memoKey, calcDoc(doc, this.links, given, [...this.stack, key], this.memo));
|
||||
if (!this.memo.has(memoKey)) {
|
||||
const shaped = coverDoc(doc, given, this.cover);
|
||||
this.memo.set(
|
||||
memoKey,
|
||||
calcDoc(shaped, this.links, given, [...this.stack, key], this.memo, this.cover),
|
||||
);
|
||||
}
|
||||
const got = this.memo.get(memoKey) as DocAnswer;
|
||||
if (!got.셈됨) {
|
||||
// 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤
|
||||
@@ -565,16 +572,17 @@ export function rulesOf(doc: FormulaDoc): Record<string, Rule | string> {
|
||||
|
||||
const STOP_KINDS = ["기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"];
|
||||
|
||||
/** 수식 문서 셈 — given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음 */
|
||||
/** 수식 문서 셈 — given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · cover = 기계 덮음(없음 = 문서 칸) · 예외 없음 */
|
||||
export function calcDoc(
|
||||
doc: FormulaDoc,
|
||||
links: DocLinks,
|
||||
given: Record<string, unknown> = {},
|
||||
stack: string[] = [],
|
||||
memo: Memo = new Map(),
|
||||
cover?: Cover,
|
||||
): DocAnswer {
|
||||
if (!stack.length && doc.키) stack = [String(doc.키)]; // 제 키 — 저를 부르면 순환
|
||||
const run = new Run(doc, links, given, stack, memo);
|
||||
const run = new Run(doc, links, given, stack, memo, cover ?? machineCover(doc));
|
||||
for (const one of check(doc)) if (STOP_KINDS.includes(one.갈래)) run.marks.push(one);
|
||||
const rules = rulesOf(doc);
|
||||
for (const name of [...run.lines.keys(), ...run.vars.keys()]) if (name) run.get(name);
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/* =============================================================================
|
||||
* common_util_formula_doc_cover.ts
|
||||
* 기계 덮음(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침(사본 줄은 그대로 · 고친 것만 따로)
|
||||
* 자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}}
|
||||
* 기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌 · 원문번호 · 개인 EQP…) — 같은 기계 줄 여럿 = 한 벌
|
||||
* 값 = 기계 식이 읽는 기계 칸(요소 `EQ:{S_기계}` 칸 — 조종원 · 조종원직종 · 잡재료율 빼고) · 조종원 = 인력 줄(노임 키 · 인/일) ·
|
||||
* 잡품 = 재료 줄(율 % · 줄 = 그 금액 줄 단가 · 수량 따름(기본) · 키 = 고른 재료 요소) · 원본과 같은 칸 = 없음(`prune`)
|
||||
* 셈 규칙(`coverDoc`) = 계산기가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 — 덮음 없음 = 문서 그대로(금액 전후 같음)
|
||||
* 파이썬 · TS 짝 — 짝 = resources/master_data/scripts/master_doc_cover.py · 거울 시험 test_836_1_machine_cover.py
|
||||
* · 화면 · DOM · fetch 없음
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
names,
|
||||
parse,
|
||||
symId,
|
||||
varId,
|
||||
type DocVar,
|
||||
type FormulaDoc,
|
||||
type MoneyLine,
|
||||
} from "./common_util_formula_doc";
|
||||
|
||||
export const COVER = "기계덮음";
|
||||
/** 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸 */
|
||||
export const NOT_VALUE = ["조종원", "조종원직종", "잡재료율"];
|
||||
const HOUR = "Q_시간환산";
|
||||
/** 조종원 줄을 새로 넣을 때(조종원 없는 식) 시간환산 — GC000267 과 같은 꼴(건설품셈 공통 1-1-3 2) */
|
||||
const HOUR_VARS: DocVar[] = [
|
||||
{
|
||||
열쇠: "Q_분자^표", 기호: "Q", 아래: "분자", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분자", 단위: "",
|
||||
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분자" },
|
||||
},
|
||||
{
|
||||
열쇠: "Q_분모^표", 기호: "Q", 아래: "분모", 위: "표", 이름: "건설기계 조종원 노임 시간 환산 계수.분모", 단위: "",
|
||||
연결: { 갈래: "표", 표: "CC000336", 조건: { 구분: "'시간환산'" }, 칸: "분모" },
|
||||
},
|
||||
{ 열쇠: "시간환산", 기호: "Q", 아래: "시간환산", 위: "", 이름: "시간환산", 단위: "", 연결: { 갈래: "식" } },
|
||||
]; // prettier-ignore
|
||||
const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" };
|
||||
|
||||
export interface CrewRow {
|
||||
키: string;
|
||||
인: number;
|
||||
}
|
||||
export interface MiscRow {
|
||||
줄?: string;
|
||||
키?: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
수량?: number;
|
||||
율: number;
|
||||
}
|
||||
export interface MachineCover {
|
||||
값?: Record<string, number | string>;
|
||||
조종원?: CrewRow[];
|
||||
잡품?: MiscRow[];
|
||||
}
|
||||
export type Cover = Record<string, MachineCover>;
|
||||
type Holder = FormulaDoc & { [COVER]?: Cover };
|
||||
|
||||
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
|
||||
const plain = (n: number): string => String(Number(n.toPrecision(15)));
|
||||
|
||||
export const coverOf = (doc: FormulaDoc | null | undefined): Cover => {
|
||||
const got = (doc as Holder | null | undefined)?.[COVER];
|
||||
return got && typeof got === "object" && !Array.isArray(got) ? got : {};
|
||||
};
|
||||
|
||||
/** 부름 입력 → 기계 열쇠(따옴표 뗌) · 기계 입력 없음 = "" */
|
||||
export function machineKey(given: Record<string, unknown> | null | undefined): string {
|
||||
const v = given?.기계;
|
||||
if (v === undefined || v === null) return "";
|
||||
const t = String(v).trim();
|
||||
return /^'.*'$/.test(t) ? t.slice(1, -1) : t;
|
||||
}
|
||||
|
||||
const namesOf = (text: unknown): string[] => {
|
||||
if (text === null || text === undefined || String(text).trim() === "") return [];
|
||||
try {
|
||||
return names(parse(text));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export interface CoverParts {
|
||||
/** 값 칸 — 칸 이름 · 그 칸을 읽는 기호(차례 = 문서 변수 차례) */
|
||||
fields: { 칸: string; ids: string[] }[];
|
||||
/** 조종원 금액 줄 차례 · 사람 수 기호 */
|
||||
crew: number[];
|
||||
crewQty: Record<number, string>;
|
||||
/** 잡품(잡재료비) 금액 줄 차례 · 율 기호 · 밑 재료 줄 차례 */
|
||||
misc: number | null;
|
||||
rate: string | null;
|
||||
base: number[];
|
||||
}
|
||||
|
||||
/** 기계 식 짜임 — 기계 칸 요소(`EQ:…{S_기계}…`) · 조종원 줄(노무비 · 수량이 칸 「조종원」) · 잡품 줄(재료비 누계 × 율) */
|
||||
export function partsOf(doc: FormulaDoc): CoverParts {
|
||||
const eq = new Map<string, string>();
|
||||
for (const v of doc.변수 ?? []) {
|
||||
const l = v.연결 ?? {};
|
||||
const k = String(l.키 ?? "");
|
||||
if (l.갈래 === "요소" && k.startsWith("EQ:") && k.includes("{S_기계}") && l.칸)
|
||||
eq.set(varId(v), l.칸);
|
||||
}
|
||||
const fields: CoverParts["fields"] = [];
|
||||
for (const [id, col] of eq) {
|
||||
if (NOT_VALUE.includes(col)) continue;
|
||||
const at = fields.find((f) => f.칸 === col);
|
||||
if (at) at.ids.push(id);
|
||||
else fields.push({ 칸: col, ids: [id] });
|
||||
}
|
||||
const lines = doc.금액 ?? [];
|
||||
const crew: number[] = [];
|
||||
const crewQty: Record<number, string> = {};
|
||||
let misc: number | null = null;
|
||||
let rate: string | null = null;
|
||||
lines.forEach((l, i) => {
|
||||
const q = namesOf(l.수량).find((n) => eq.get(n) === "조종원");
|
||||
if (l.비목 === "노무비" && q) {
|
||||
crew.push(i);
|
||||
crewQty[i] = q;
|
||||
}
|
||||
const p = namesOf(l.단가);
|
||||
const other = p.filter((n) => n !== "재료비");
|
||||
if (misc === null && l.비목 === "재료비" && p.includes("재료비") && other.length === 1) {
|
||||
misc = i;
|
||||
rate = other[0];
|
||||
}
|
||||
});
|
||||
const base =
|
||||
misc === null
|
||||
? []
|
||||
: lines.flatMap((l, i) => (i < misc! && l.비목 === "재료비" && !l.합밖 ? [i] : []));
|
||||
return { fields, crew, crewQty, misc, rate, base };
|
||||
}
|
||||
|
||||
type Vals = Record<string, unknown>;
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
/** 요소 키 글의 구멍을 값으로 채움(`LB:{S_직종}` → `LB:1048`) */
|
||||
const fillKey = (text: string, vals: Vals): string =>
|
||||
text.replace(/\{([^{}]+)\}/g, (_, h: string) => {
|
||||
const v = vals[h];
|
||||
return typeof v === "number" ? plain(v) : String(v ?? "");
|
||||
});
|
||||
|
||||
/** 원본(덮음 없음) 기본값 — 셈 답 값(`vals` = 그 기계로 셈한 원본 답 `값`) */
|
||||
export function defaultsOf(doc: FormulaDoc, vals: Vals): Required<MachineCover> {
|
||||
const parts = partsOf(doc);
|
||||
const lines = doc.금액 ?? [];
|
||||
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
|
||||
const 값: Record<string, number | string> = {};
|
||||
for (const f of parts.fields) {
|
||||
const v = vals[f.ids[0]];
|
||||
if (typeof v === "number" || typeof v === "string") 값[f.칸] = v;
|
||||
}
|
||||
const 조종원 = parts.crew.flatMap((i) => {
|
||||
const n = num(vals[parts.crewQty[i]]);
|
||||
const sym = namesOf(lines[i].단가)[0];
|
||||
const key = String(vars.get(sym ?? "")?.연결?.키 ?? "");
|
||||
return n && key ? [{ 키: fillKey(key, vals), 인: n }] : [];
|
||||
});
|
||||
const r = parts.rate ? num(vals[parts.rate]) : null;
|
||||
const 잡품 = r ? parts.base.map((i) => ({ 줄: String(lines[i].이름 ?? ""), 율: r })) : [];
|
||||
return { 값, 조종원, 잡품 };
|
||||
}
|
||||
|
||||
const canon = (v: unknown): string =>
|
||||
JSON.stringify(v, (_, x: unknown) =>
|
||||
x && typeof x === "object" && !Array.isArray(x)
|
||||
? Object.fromEntries(
|
||||
Object.entries(x as Record<string, unknown>)
|
||||
.filter(([, y]) => y !== undefined && y !== null && y !== "")
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1)),
|
||||
)
|
||||
: typeof x === "number"
|
||||
? Number(x.toPrecision(12))
|
||||
: x,
|
||||
);
|
||||
const sameVal = (a: unknown, b: unknown): boolean =>
|
||||
typeof a === "number" && typeof b === "number"
|
||||
? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b))
|
||||
: String(a) === String(b);
|
||||
|
||||
/** 원본과 같은 칸 · 줄을 뺀 덮음(바꿨다 되돌림 = 안 고침) · 다 빠지면 null */
|
||||
export function prune(entry: MachineCover, base: Required<MachineCover>): MachineCover | null {
|
||||
const out: MachineCover = {};
|
||||
const vals = Object.entries(entry.값 ?? {}).filter(
|
||||
([k, v]) =>
|
||||
v !== null && v !== undefined && v !== "" && !(k in base.값 && sameVal(v, base.값[k])),
|
||||
);
|
||||
if (vals.length) out.값 = Object.fromEntries(vals);
|
||||
if (entry.조종원 && canon(entry.조종원) !== canon(base.조종원)) out.조종원 = entry.조종원;
|
||||
if (entry.잡품 && canon(entry.잡품) !== canon(base.잡품)) out.잡품 = entry.잡품;
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** 덮음 한 기계 고침 → 문서 칸(원본 대조로 정리 · 비면 칸 지움) */
|
||||
export function putCover(
|
||||
doc: FormulaDoc,
|
||||
machine: string,
|
||||
entry: MachineCover,
|
||||
base: Required<MachineCover>,
|
||||
): void {
|
||||
const box = doc as Holder;
|
||||
const all = { ...coverOf(doc) };
|
||||
const kept = prune(entry, base);
|
||||
if (kept) all[machine] = kept;
|
||||
else delete all[machine];
|
||||
if (Object.keys(all).length) box[COVER] = all;
|
||||
else delete box[COVER];
|
||||
}
|
||||
|
||||
// ── 셈 규칙 ───────────────────────────────────────────────────────────
|
||||
/** 새 요소 기호(`w_조종원1` · `P_잡품1` — 문서에 없는 첫 번호) */
|
||||
function addVar(doc: FormulaDoc, sign: string, low: string, name: string, key: string): string {
|
||||
const have = new Set([
|
||||
...(doc.변수 ?? []).map(varId),
|
||||
...(doc.식 ?? []).map((l) => String(l.기호 ?? "")),
|
||||
]);
|
||||
let n = 1;
|
||||
while (have.has(symId(sign, `${low}${n}`))) n++;
|
||||
const 아래 = `${low}${n}`;
|
||||
const id = symId(sign, 아래);
|
||||
(doc.변수 ??= []).push({
|
||||
열쇠: id, 기호: sign, 아래, 위: "", 이름: name, 단위: "", 연결: { 갈래: "요소", 키: key },
|
||||
}); // prettier-ignore
|
||||
return id;
|
||||
}
|
||||
|
||||
function crewLines(doc: FormulaDoc, rows: CrewRow[]): MoneyLine[] {
|
||||
const have = (doc.변수 ?? []).some((v) => varId(v) === HOUR) || (doc.식 ?? []).some((l) => l.기호 === HOUR); // prettier-ignore
|
||||
if (rows.length && !have) {
|
||||
const ids = new Set((doc.변수 ?? []).map(varId));
|
||||
for (const v of HOUR_VARS) if (!ids.has(varId(v))) (doc.변수 ??= []).push(clone(v));
|
||||
(doc.식 ??= []).push({ ...HOUR_LINE });
|
||||
}
|
||||
return rows.map((r) => ({
|
||||
이름: "조종원",
|
||||
단위: "인",
|
||||
비목: "노무비",
|
||||
수량: `${plain(r.인)} × ${HOUR}`,
|
||||
단가: addVar(doc, "w", "조종원", "조종원 노임", r.키),
|
||||
}));
|
||||
}
|
||||
|
||||
function miscLines(doc: FormulaDoc, rows: MiscRow[], lines: MoneyLine[]): MoneyLine[] {
|
||||
return rows.flatMap((r) => {
|
||||
let price: string;
|
||||
let qty: string;
|
||||
let label = r.이름 ?? "";
|
||||
if (r.키) {
|
||||
price = addVar(doc, "P", "잡품", label || r.키, r.키);
|
||||
qty = plain(r.수량 ?? 1);
|
||||
} else {
|
||||
const b = lines.find((l) => String(l.이름 ?? "") === r.줄);
|
||||
if (!b || !b.단가) return [];
|
||||
price = String(b.단가);
|
||||
qty = r.수량 !== undefined && r.수량 !== null ? plain(r.수량) : `(${b.수량 ?? 1})`;
|
||||
label ||= String(b.이름 ?? "");
|
||||
}
|
||||
return [{ 이름: "잡품", 규격: label, 단위: "", 비목: "재료비", 수량: `${qty} × ${plain(r.율)} ÷ 100`, 단가: price } as MoneyLine]; // prettier-ignore
|
||||
});
|
||||
}
|
||||
|
||||
/** 기계 식 문서에 그 기계 덮음을 얹음(새 문서) — 덮음 없음 = 그대로(같은 물건) */
|
||||
export function coverDoc(
|
||||
doc: FormulaDoc,
|
||||
given: Record<string, unknown> | null | undefined,
|
||||
cover: Cover | null | undefined,
|
||||
): FormulaDoc {
|
||||
const entry = cover?.[machineKey(given)];
|
||||
if (!entry || !machineKey(given)) return doc;
|
||||
const out = clone(doc) as FormulaDoc & { 쌓기?: unknown };
|
||||
delete out.쌓기; // 금액 차례가 바뀜 — 기본 차림
|
||||
const parts = partsOf(out);
|
||||
for (const f of parts.fields)
|
||||
if (entry.값 && f.칸 in entry.값)
|
||||
for (const v of out.변수 ?? [])
|
||||
if (f.ids.includes(varId(v))) v.연결 = { 갈래: "숫자", 값: entry.값[f.칸] };
|
||||
const lines = out.금액 ?? [];
|
||||
const crew = entry.조종원 ? crewLines(out, entry.조종원) : null;
|
||||
const misc = entry.잡품 && (parts.misc !== null || entry.잡품.length) ? miscLines(out, entry.잡품, lines) : null; // prettier-ignore
|
||||
const gone = new Set([...(crew ? parts.crew : []), ...(misc && parts.misc !== null ? [parts.misc] : [])]); // prettier-ignore
|
||||
const tail = lines.findIndex((l) => l.비목 === "경비");
|
||||
const end = tail < 0 ? lines.length : tail;
|
||||
const crewAt = parts.crew.length ? parts.crew[0] : end;
|
||||
const miscAt = parts.misc ?? end;
|
||||
const next: MoneyLine[] = [];
|
||||
for (let i = 0; i <= lines.length; i++) {
|
||||
if (crew && i === crewAt) next.push(...crew);
|
||||
if (misc && i === miscAt) next.push(...misc);
|
||||
if (i < lines.length && !gone.has(i)) next.push(lines[i]);
|
||||
}
|
||||
out.금액 = next;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
# 조사 858 — 단가산출 산출근거 기호 표기 (읽기 전용 조사)
|
||||
|
||||
표기: 【사실】 = 파일에서 직접 확인 · 【추정】 = 짐작 · 못 찾음 = 자료 없음.
|
||||
|
||||
## 0. 한눈에
|
||||
|
||||
| 물음 | 답 |
|
||||
| --- | --- |
|
||||
| 경쟁사는 기계경비 안쪽(상각 · 정비 · 관리)을 산출근거에 푸나 | 안 풂 — 손료는 계수 한 수(2,085)만 · 상각 · 정비 · 관리 셋 따로 안 보임 【사실】 |
|
||||
| 경쟁사 작업량 식 기호 | 품셈 그대로 q · f · K · Cm · E · Q · 뒤에 괄호 한글 뜻 【사실】 |
|
||||
| 품셈 8장에 기계경비 기호가 있나 | 없음 — 용어(상각비계수 · 정비비계수 · 관리비계수 · 시간당 손료 · 주연료 · 잡재료 · 조종원)만 【사실】 |
|
||||
| 우리 작업량 식 기호 | 이미 품셈 기호 그대로(GC001147 = q · K · f · E · cm · Q) 【사실】 |
|
||||
| 사용자가 본 `P_기계` · `k_기계상각비` | 기계경비식 16 문서의 우리 발명 기호 — 품셈에 대응 기호 없음 【사실】 |
|
||||
|
||||
## 1. 경쟁사 표기 (원문 그대로)
|
||||
|
||||
자료 자리 — 저장소 `resources/knowledge/original/실무문서/` 실무 엑셀 (읽기 전용 열람).
|
||||
- 견본 A · B · C = 울진 대흥 공통 xlsx `2024년 간선임도 신설사업(기번3 울진.울진.대흥.산65외2).xlsx` — 시트 「단가산출근거」 · 「중기사용료」 · 「중기목록표」 (G 칸 `BDCOD|…` 코드 = STmate 내보내기 꼴 【사실】 · 어느 제품 파일인지 문서 표기는 못 찾음 【추정】 STmate)
|
||||
- 견본 D · E = 거창 xlsx `01.산불거창…/00.25년_산불_거창…내역+수량.xlsx` — 시트 「기계경비」 · 「단가산출」 (같은 폴더 `.estx` 짝 = EST, 시험 `test_812_1_xlsx_golden.py` 머리글 【사실】)
|
||||
|
||||
### 견본 A — STmate 「단가산출근거」 제3호표 (굴삭기 0.7㎥ 작업량 + 비목 줄)
|
||||
|
||||
```
|
||||
①기계: 굴삭기 0.7m3
|
||||
q (버킷용량) = 0.7 , f (체적환산계수) = 1/1.125 = 0.89
|
||||
K (버킷계수) = 0.55
|
||||
Cm (사이클시간) = 20 sec (135˚)
|
||||
E (작업효율) = 0.35
|
||||
Q (시간당작업량) = 3600*q*K*f*E/Cm = 21.59 m3/hr
|
||||
노 무 비 : 55,700 / Q * 2 = 5,159.7
|
||||
재 료 비 : 18,001 / Q * 2 = 1,667.5
|
||||
경 비 : 26,677 / Q * 2 = 2,471.2
|
||||
```
|
||||
|
||||
### 견본 B — 같은 시트 「덤프트럭 2.5톤 운반」 (시트 145~167행)
|
||||
|
||||
```
|
||||
L (운반거리) = 0.1 Km
|
||||
E (작업효율) = 0.9 , f (체적환산계수) = 1
|
||||
q1 (버킷용량) = (2.5/1.7) * 1.0 = 1.47
|
||||
n (덤프트럭1대의 토량을 적재하는데 소요되는적재기계의 사이클횟수)
|
||||
n = q1 / (0.70 * k) = 3.82 회
|
||||
t1 (적재시간) = 15 * n / (60 * 0.45) = 2.12 분
|
||||
t2 (왕복시간) = (L/10+L/15)* 60 = 1.00 분
|
||||
t3 (적하시간) = 0.8 분 · t4 (적재작업이 시작될때까지의시간) = 0.7 분
|
||||
Cm (1회 사이클 시간(분)) = t1 + t2 + t3 + t4 = 4.62 분
|
||||
Q1 (시간당작업량) = 60 * q1 * f * E / Cm = 17.18 m3/hr
|
||||
```
|
||||
|
||||
### 견본 C — STmate 「중기사용료」 (기계 시간당 경비 = 표 5 줄 · 시트 26~32 · 48~54 · 55~60행)
|
||||
|
||||
| 이름 | 규격 | 수량 | 단위 | 단가 | 금액 |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 굴삭기(0.7m3) | 무한궤도 | 0.2085 | 천원 | 110,926 | 23,128 |
|
||||
| 건설기계운전사 | | 1 | 인 | 55,700 | 55,700 |
|
||||
| 경유 | 저유황(0.05%) | 11.6 | L | 1,272 | 14,755.2 |
|
||||
| 잡품 | 주연료의 % | 22 | % | 14,755.2 | 3,246.1 |
|
||||
| 합 계 | | | | | 96,829 |
|
||||
|
||||
- 굴삭기+브레카(0.7m3) = 굴삭기 0.2085 천원 · 대형브레이카(0.7m3) 0.6601 천원 · 운전사 1 인 · 경유 11.6 L · 잡품 16 % (제7호표) 【사실】
|
||||
- 덤프트럭15.0톤 = 0.2279 천원 · 운전사 · 경유 15.9 L · 잡품 38 % (제8호표) 【사실】
|
||||
- 손료계수 = 수량 칸에 합계 한 수(2,085 × 10⁻⁷ × 1,000 = 0.2085 천원) · 상각 900 · 정비 700 · 관리 485 따로 안 보임 【사실】
|
||||
|
||||
### 견본 D — EST 짝 「기계경비」 시트 (거창 xlsx · 4~27행)
|
||||
|
||||
```
|
||||
1. 굴삭기(무한궤도) 0.7㎥
|
||||
손 료 117766000 ₩× 2085 × 10(-7)
|
||||
주연료 경유 11.6 ℓ × 1376
|
||||
잡 품 주연료의 22 % × 15961.6
|
||||
건설기계운전사 1 인 × 283297 × 1/8*16/12*25/20
|
||||
2. 굴삭기(무한궤도) 0.7+브레이카
|
||||
굴삭기손료 117766000 ₩× 2085 × 10(-7)
|
||||
브레이카손료 17289000 ₩× 6601 × 10(-7)
|
||||
주연료 경유 11.6 ℓ × 1376 · 잡 품 주연료의 16 % × 15961.6 · 건설기계운전사 1 인 …
|
||||
4. 덤프트럭 15Ton
|
||||
손 료 90653000 ₩× 2279 × 10(-7)
|
||||
주연료 경유 15.9 ℓ × 1376 · 잡 품 주연료의 38 % × 21878.4 · 건설기계운전사 1 인 …
|
||||
```
|
||||
|
||||
- 굴착기 1.0㎥ 짝 = 같은 꼴에 수만 다름: 손료 계수 2,085 · 가격 142,069,000 원 · 경유 19.5 ℓ · 잡품 22 % (`docs/raw/요구_1104/_방향_불러오기.md` N1 · 시트 꼴은 위와 같다고 봄 【추정】)
|
||||
|
||||
### 견본 E — EST 짝 「단가산출」 (거창 xlsx · 197~251행 · 칸 나눠 그린 식)
|
||||
|
||||
```
|
||||
q = 0.7 ㎥ K = 0.9 f = 1. / 1.325
|
||||
E = 0.75 Cm = 22. sec ( 180 ˚ )
|
||||
3,600. × q × K × f × E
|
||||
Q = ───────────────────────────
|
||||
Cm
|
||||
노무비 : 59,020 ÷ 57.99 = 1,017.7
|
||||
재료비 : 19,473 ÷ 57.99 = 335.7
|
||||
경 비 : 24,554 ÷ 57.99 = 423.4
|
||||
|
||||
2. 운 반 : 덤프트럭 (15Ton)
|
||||
L = 184.81 m T = 15. ton rt = 1.6 ton / ㎥
|
||||
Qt = T ÷ rt × L1 = … n = Qt / (q × K)
|
||||
t1 = Cms × n / (60. × Es) t2 = (L/7. + L/8.) × 60. ÷ 1,000. t3 = 1.1 분(적하) t4 = 0.7 분(대기)
|
||||
Cm = t1 + t2 + t3 + t4 + t5 OH = (Cm − t1) / Cm
|
||||
Q = 60. × Qt × f × E / Cm
|
||||
```
|
||||
|
||||
### 경쟁사 표기 규칙 (위 다섯에서 읽은 것)
|
||||
|
||||
| 항목 | 규칙 |
|
||||
| --- | --- |
|
||||
| 기호 | 품셈 기호 그대로(q · f · K · Cm · E · Q · L · n · t1~t4 · Qt · OH) · 대문자 Cm 【사실】 |
|
||||
| 뜻 | STmate = 기호 뒤 괄호 한글 `q (버킷용량) = 0.7` · EST = 뜻 없이 기호만 【사실】 |
|
||||
| 줄 구성 | `기호 = 식 = 값 단위` 한 줄(견본 A `Q (시간당작업량) = 3600*q*K*f*E/Cm = 21.59 m3/hr`) 【사실】 |
|
||||
| 기계경비 | 산출근거 안에 안 풂 — 비목 줄은 `단가 / Q` 숫자뿐 · 풀이는 별도 시트(중기사용료 · 기계경비) 【사실】 |
|
||||
| 기계경비 줄 | 손료(계수 한 수) · 주연료 · 잡품(주연료의 %) · 운전사 — 4~5 줄 · 이름 글만(기호 없음) 【사실】 |
|
||||
| 상각 · 정비 · 관리 | 산출근거 · 중기사용료에 안 나옴 · STmate 편집창에 입력 칸(계 = 셋 합 · `ui_code_amt.js` `wM_Edit_XRate`) 【사실】 |
|
||||
|
||||
## 2. 품셈 원문 표기 (2026 건설공사 표준품셈 · 제8장)
|
||||
|
||||
원문 폴더 = `resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계/`
|
||||
|
||||
### 작업량 식 기호
|
||||
|
||||
| 기호 | 뜻 | 원문 자리 |
|
||||
| --- | --- | --- |
|
||||
| Q = n·q·f·E | 시간당 작업량 · n 시간당 작업싸이클 수 · q 1회 싸이클당 표준작업량 · f 체적환산계수 · E 작업효율 | `8-01_적용기준.md` 8-1-4 |
|
||||
| n = 60/cm(min) 또는 3,600/cm(sec) | cm = 싸이클시간(소문자) | 8-1-4 [주]⑤ |
|
||||
| E = 현장 작업능력계수 × 실작업시간율 | | 8-1-4 [주]⑥ |
|
||||
| Q = 3,600·q·k·f·E / cm | q 버킷용량(㎥) · f · E · K 버킷계수 · cm 1회 싸이클 시간(초) — 식은 소문자 k · 풀이는 대문자 K | `8-02-03_굴착기.md` |
|
||||
| Q = 60·q·f·E / cm · q = qo×e | q 삽날 용량 · qo 거리 안 본 삽날 용량 · e 운반거리계수 · cm 1회 싸이클 시간 | `8-02-01_불도저.md` |
|
||||
| Q = 60·q·f·E / cm · q = T/γt·L | T 적재용량(ton) · γt 습윤밀도 · L 체적변화율 · E 0.9 · cm = t1+…+t6 | `8-02-08_덤프트럭.md` |
|
||||
|
||||
### 기계경비 말(기호 없음)
|
||||
|
||||
| 말 | 뜻 | 원문 자리 |
|
||||
| --- | --- | --- |
|
||||
| 취득가격 · 평균취득가격 | 평균취득가격 = 취득가격×(1.1×내용년수+0.9)/(2×내용년수) | 8-1-5 6 · 7 |
|
||||
| 시간당 손료 | 취득가격 × 시간당 손료계수 합계(원 미만 절사) | 8-1-5 12 |
|
||||
| 기계손료 = 상각비 + 정비비 + 관리비 | 관리비는 1일 8시간 상한 | 8-1-6 2 |
|
||||
| 기계가격(천원) | 굴착기(무한궤도) 1.0㎥ = 142,069 | `8-05_기계가격.md` |
|
||||
| 내용시간 · 연간표준가동시간 · 상각비율 · 정비비율 · 연간관리비율 | 10,000 · 1,250 · 0.9 · 0.7 · 0.1 | `8-03-01_[00]토공기계.md` (0201) 표 머리 |
|
||||
| 시간당(10⁻⁷) 상각비계수 · 정비비계수 · 관리비계수 · 계 | 900 · 700 · 485 · 2,085 | 같은 표 (0201-0100) |
|
||||
| 주연료(ℓ/hr) · 잡재료(주연료의 %) · 조종원(인/일) | 19.5 · 22 · 1 | `8-04_운전경비_산정.md` 8-4-1 |
|
||||
| 가산비율(%) | 암석 20 · 전석섞인토사 10 (굴착기 무한궤도) | 8-1-7 1 |
|
||||
|
||||
## 3. 우리 표기 사실
|
||||
|
||||
### 기호가 정해지는 길
|
||||
|
||||
| 자리 | 내용 |
|
||||
| --- | --- |
|
||||
| 자료 | `resources/master_data/수식문서/수식_*.json` 59 파일 · 1,520 문서 · 변수 19,209 — 변수마다 `기호` · `아래`(아래첨자) · `위` · `이름` · `단위` 칸 【사실】 |
|
||||
| 글자 만들기 | 화면은 `기호_아래^위` 로 합쳐 TeX 로 그림(`A00_Common/line_stack_tex.ts` `symTex`) · 한글 아래첨자는 `\text{}` 로 그림 【사실】 |
|
||||
| 기본 기호 규칙 | `resources/master_data/scripts/master_symbol.py` — 이름 끝말로 머리글자(계수 k · 율 r · 단가 P · 노임 w · 수량 Q …) + 아래 = 이름 알맹이 6 자 · 원문 기호(`변수표_796_3.json`)와 사용자가 정한 칸은 덮지 않음 【사실】 |
|
||||
| 표 값 이름 | `tableAlias`(`common_util/common_util_line_stack_pick.ts` 213행)가 `Q_분자^표` · `Q_분모^표` 를 조건 글 `'시간환산'` 따라 `Q_시간환산2` · `Q_시간환산3` 으로 바꿔 그림 — 화면의 `Q_시간환산2 = 1 · Q_시간환산3 = 8` 이 이것 【사실】 |
|
||||
| id 글 | 식 · 금액 글 안에서 변수를 `기호_아래` 글로 부름(예 `P_기계 × 1000 × ((k_기계상각비 + …`) 【사실】 |
|
||||
| 호출 문서 | 부르는 쪽 입력 이음은 `열쇠`(기계 · 지역 · 보정작업)로 넘김 — 기호 글에 안 기댐(GC000089 의 `P_굴착기무한궤` 연결로 확인) 【사실】 |
|
||||
| 기호 글을 부르는 다른 코드 · 시험 | `k_기계상각비` · `P_기계주연료` · `Q_시간환산` 은 수식문서 json 3 파일에만 있음(코드 · 시험 0) · 프로젝트 storage 사본은 안 봄 — 못 찾음 |
|
||||
|
||||
### 기계경비식 (GC000267 등 · `부품.갈래 = 기계경비식`)
|
||||
|
||||
- 16 문서(건설 8장 14 + 산림 2) · 변수 243 · 서로 다른 기호꼴 64 · 아래첨자 없는 변수 0 · 품셈 원문 기호와 같은 것 0(품셈에 기호가 없음) 【사실】
|
||||
- GC000267 변수 대응
|
||||
|
||||
| 우리 기호 | 뜻(원문 말) | 원문 기호 |
|
||||
| --- | --- | --- |
|
||||
| P_기계 | 취득가격(천원) | 없음 |
|
||||
| k_기계상각비 · k_기계정비비 · k_기계관리비 | 시간당 상각비계수 · 정비비계수 · 관리비계수 | 없음 |
|
||||
| C_손료 | 시간당 손료 | 없음 |
|
||||
| r_가산 | 가산비율(8-1-7) | 없음 |
|
||||
| P_기계주연료 | 주연료(ℓ/hr) — 양인데 가격 글자 P | 없음 |
|
||||
| Q_기계조종원 | 조종원(인/일) | 없음 |
|
||||
| r_기계잡재료 | 잡재료(주연료의 %) | 없음 |
|
||||
| Q_시간환산 | 조종원 노임 시간 환산 = 분자/분모 표값 1/8 | 없음(우리 이음) |
|
||||
| S_기계 · S_지역 · S_보정작업 · S_연료 · S_직종 | 고르기 칸 | 없음 |
|
||||
| w_기계운전사 · P_자동차용경유 | 노임 · 유가 | 없음 |
|
||||
|
||||
### 작업량 식 (GC001143 · GC001147 · 시공능력 41 문서 · 변수 654)
|
||||
|
||||
| 갈래 | 개수 | 예 |
|
||||
| --- | --- | --- |
|
||||
| 원문 기호 그대로(아래 없음) | 233 (36 %) | cm · q · f · E · n · Q · L · e · t |
|
||||
| 원문 기호 + 숫자 첨자 | 92 (14 %) | q_0 · V_1 · V_2 · t_1 · t_2 · L_1 · L_2 · E_2 |
|
||||
| 우리 고르기 칸 `S_…` | 111 (17 %) | S_종별 · S_현장조건 · S_등급 · S_토질명 |
|
||||
| 우리 표 값 칸 `…^표` | 110 (17 %) | Q_보정값 · v_속도 · T_시간 · k_cm작업거리 |
|
||||
| 기호 + 한글 아래(우리 이름) | 69 (11 %) | θ_선회 · X_싸이클 · N_발톱 · L_주행 · V_적재함 · W_단위 |
|
||||
| 우리 변형 | 39 (6 %) | E_입력 · E_보정 · r_보정 |
|
||||
|
||||
- 원문 기호와 다른 것 = 329 / 654 (50 %) — 거의 전부 원문에 기호가 없는 고르기 · 표 값 · 입력 칸 【사실】
|
||||
- GC001147 굴착기 작업량 식 = 품셈과 같은 `Q = 3600 × q × K × f × E ÷ cm` (cm · K 모양 그대로) 【사실】
|
||||
- `Cm`(대문자) = 산림품셈 문서 24 변수 · `cm`(소문자) = 건설 8장 18 변수 — 경쟁사 · 산림품셈은 Cm · 건설 8장 원문은 cm 【사실】
|
||||
- 대표 예 20 (원문 ↔ 우리)
|
||||
|
||||
| # | 원문 | 우리 |
|
||||
| --- | --- | --- |
|
||||
| 1 | q 버킷용량 | q (같음) |
|
||||
| 2 | f 체적환산계수 | f (같음) |
|
||||
| 3 | E 작업효율 | E (같음) · 입력 E_입력 · 보정 E_보정 · 표값 E_2 |
|
||||
| 4 | K 버킷계수 | K (같음) |
|
||||
| 5 | cm 싸이클시간 | cm (같음 · 경쟁사 Cm) |
|
||||
| 6 | Q 시간당 작업량 | Q (같음) |
|
||||
| 7 | n 시간당 싸이클 수 | n (같음) |
|
||||
| 8 | qo 거리 안 본 삽날 용량 | q_0 |
|
||||
| 9 | e 운반거리계수 | e (같음) |
|
||||
| 10 | V1 · V2 전진 · 후진속도 | V_1 · V_2 |
|
||||
| 11 | t1 ~ t6 | t_1 ~ t_6 |
|
||||
| 12 | L 운반거리 | L (같음) · 덤프에서는 L 이 체적변화율 · 거리는 L_운반 |
|
||||
| 13 | γt 습윤밀도 | W_단위 |
|
||||
| 14 | 취득가격 | P_기계 |
|
||||
| 15 | 상각비계수 | k_기계상각비 |
|
||||
| 16 | 정비비계수 | k_기계정비비 |
|
||||
| 17 | 관리비계수 | k_기계관리비 |
|
||||
| 18 | 시간당 손료 | C_손료 |
|
||||
| 19 | 주연료(ℓ/hr) | P_기계주연료 |
|
||||
| 20 | 조종원 · 잡재료 · 가산비율 | Q_기계조종원 · r_기계잡재료 · r_가산 |
|
||||
|
||||
## 4. 대조 표 — 굴착기(무한궤도) 시간당 경비
|
||||
|
||||
값은 우리 화면 기준 굴착기 1.0㎥(P 142,069 · 900 / 700 / 485 · 경유 19.5 ℓ · 잡 22 %).
|
||||
|
||||
| 항목 | ㉠ 지금 우리 | ㉡ 경쟁사(EST 기계경비 꼴) | ㉢ 품셈 원문 말 |
|
||||
| --- | --- | --- | --- |
|
||||
| 가격 | `P_기계 = 142,069` | `손 료 142,069,000 ₩×` (원 단위로 적음) | 취득가격(천원) 142,069 |
|
||||
| 계수 | `k_기계상각비 = 900 · k_기계정비비 = 700 · k_기계관리비 = 485` | `2085 × 10(-7)` (한 수) | 상각비계수 900 · 정비비계수 700 · 관리비계수 485 · 계 2,085 |
|
||||
| 손료 식 | `C_손료 = P_기계 × 1,000 × ((k_기계상각비 + k_기계정비비) × (1 + r_가산/100) + k_기계관리비) / 10,000,000 = 29,621` | (식 없음 · 가격 × 계수 줄 한 줄) | 시간당 손료 = 취득가격 × 시간당 손료계수 합계 · 가산비율 8-1-7 |
|
||||
| 연료 | `P_기계주연료 = 19.5` · `주연료 : 1,846.39 × 19.5 ℓ = 36,004.6` | `주연료 경유 19.5 ℓ × 1376` | 주연료 19.5 ℓ/hr |
|
||||
| 잡품 | `r_기계잡재료 = 22` | `잡 품 주연료의 22 % × …` | 잡재료 22 (주연료의 %) |
|
||||
| 조종원 | `Q_기계조종원 = 1 · Q_시간환산 = 1/8` | `건설기계운전사 1 인 × 노임 × 1/8*16/12*25/20` | 조종원 1 (인/일) |
|
||||
| 작업량 | `Q = 3600 × q × K × f × E ÷ cm` | `Q (시간당작업량) = 3600*q*K*f*E/Cm` | `Q = 3,600·q·k·f·E / cm` |
|
||||
|
||||
- 조종원 시간환산: 경쟁사 `1/8*16/12*25/20`(= 0.2083) ↔ 우리 `1/8` — 값 차이는 이 조사 범위 밖 · 사실만 적음 【사실】
|
||||
|
||||
## 5. 「깔끔한」 표기 안
|
||||
|
||||
| 안 | 값 줄 | 식 줄 |
|
||||
| --- | --- | --- |
|
||||
| 안 1 품셈 말 그대로 | `취득가격 142,069 천원 · 상각비계수 900 · 정비비계수 700 · 관리비계수 485` | `손료 = 취득가격 × 1,000 × ((상각비계수 + 정비비계수) × (1 + 가산비율/100) + 관리비계수) / 10⁷ = 29,621` |
|
||||
| 안 2 짧은 한글 이름(사용자 예) | `가격 142,069 천원 · 상각 900 · 정비 700 · 관리 485` | `손료 = 가격 × 1,000 × ((상각 + 정비) × (1 + 가산/100) + 관리) / 10⁷ = 29,621` |
|
||||
| 안 3 경쟁사 줄 꼴 | `손료 142,069,000 원 × 2,085 × 10⁻⁷ = 29,621` / `주연료 경유 19.5 ℓ × 1,846.39` / `잡품 주연료의 22 %` / `조종원 1 인 × …` | 식 줄 없이 비목 줄만 — 상각 · 정비 · 관리 · 가산비율이 안 보임 |
|
||||
|
||||
작업량 식(q · K · f · E · cm · Q)은 세 안 모두 지금 그대로 — 품셈 기호가 이미 쓰임.
|
||||
|
||||
### 안별 바뀌는 범위
|
||||
|
||||
| 안 | 자료 파일 | 그리는 코드 | 영향 문서 | 메모 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 그리기만 바꿈(안 1 · 2 공통 방법) | 0 | `A00_Common/line_stack_tex.ts`(기호 → 글) · `common_util/common_util_line_stack_pick.ts`(`tableAlias` 옆 그림 전용 사전) 2 파일 + 시험 | 기계경비식 16 문서 + 그것을 펼쳐 보이는 484 문서의 펼친 블록 | 식 · 열쇠 · 계산 불변 · 왼쪽 영역 · 복사(`formula_doc_copy.ts`)가 같은 사전을 타는지는 못 확인 【추정】 |
|
||||
| 자료를 바꿈(안 1 · 2) | `수식_건설품셈_08장_건설기계.json` 14 문서 + 산림 02장 · 10장 각 1 문서(변수 243 · 식 · 금액 글 안 id 포함) | 없음(그림은 기호 글을 그대로 그림) | 16 문서 · 부르는 쪽은 열쇠 이음이라 불변 | 마스터 규칙 `master_symbol.py` 가 한글 기호를 「기호 꼴」로 안 쳐서 빌더 재실행 때 되돌릴 가능성 【추정】 · 사용자 마스터 편집 길과 겹침 |
|
||||
| 안 3 줄 꼴 | 0~16 문서(금액 줄 · 수식 구성 바꿈) | 기계경비식 전용 그림 틀 신설 | 16 문서 + 펼친 484 | 상각 · 정비 · 관리 · 가산비율 식이 가려져 계산 검증성 하락 |
|
||||
|
||||
## 6. 판정 한 줄
|
||||
|
||||
그림 전용 사전으로 안 1(품셈 말 그대로)을 먼저 보이고 안 2 와 견줌 — 자료 0 건 · 되돌리기 쉬움 · 결정은 사용자 몫.
|
||||
@@ -1353,3 +1353,34 @@ est원본에서 잘린 글씨는 보이는 만큼만 서울/경기로 표현하
|
||||
- 지금 내 기계와 내 재료의 운영이 조금 애매한것같은데 어떻게 생각하는지 말해줘.
|
||||
- 공용 단가산출의 페이지 구성에서 보기 방식을 단가산출 상세 보기 탭이 의미가 없어보임. 혼선만 주는것같음. 구성에서 제외
|
||||
일단 위 내용으로 반영하고 나는 프로그래머가 아니라 알수 없으니 반영된 화면을 보고 판단하겠음.
|
||||
|
||||
## 요구 855 (11-15 · 사용자 · 추가 업무 — 일위대가 불러오기의 개인 / 회사 / 마스터 분류 걷음 · 무조건 개인의 단가 · 일위대가로 가져옴 · 필요하면 본인이 공유)
|
||||
|
||||
추가 업무
|
||||
- 일위대가 불러오기에 개인/회사/마스터가 분류되어 있음. 분류될 필요 없음. 무조건 개인의 단가나 일위대가로 가지고 오며 필요시 본인이 단가산출이나 일위대가로 공유하면 됨.
|
||||
|
||||
## 요구 856 (11-15 · 사용자 · 추가 업무 — 엑셀 불러오기에서 단가산출 수식 보기 탭이 「저장한 단가산출만」 이라고 나옴 · 단가산출 상세 보기가 없어질 예정이니 수식 보기가 되어야 함)
|
||||
|
||||
추가 업무 엑셀불러오기에서 단가산출의 수식 보기 탭에 단가산출이 저장한 단가산출만 이라고 나옴. 단가산출 상세 보기가 없어질 예정이니 보기가 되어야함.
|
||||
|
||||
## 요구 857 (11-15 · 사용자 · 추가 업무 — 단가산출 변수 기호: 품셈에 이미 정의된 q · E · K · f · Cm 등은 단가에서 변경될 필요 없음 · 인건비 · 자재는 지금 하첨 형태 → 비슷한 형태로 변경 필요 · 뜻 확인 중)
|
||||
|
||||
추가업무
|
||||
단가산출의 에서 변수들의 품셈에서 q, E, K, f, Cm등 벌써 정의 되어 있는데 단가에서 변경될 필요는 없으며 현재 인건비와 자재에서도 인력 하첨 형태로 나와있는데 비슷한 형태로 변경이 필요해 보임.
|
||||
|
||||
## 요구 858 (11-15 · 사용자 · 857 답 — 품셈에서 이미 정의된 기호는 좌우 영역에서 바꿀 필요 없음(사용자가 바꾸는 것은 별개) · 기계 시간당 경비의 P기계 · k기계상각비 표기 → 경쟁사 표현 확인(깔끔함) · 그림 요구858_기계경비_기호표기.png / GC000089 단가산출 기초 항목 하나 삭제 뒤 되돌림 안 됨 → 확인 · 개선 · 데이터 원복)
|
||||
|
||||
기호 이름을 사용자가 바꾸면 어쩔수 없지만 벌써 품셈에서 정의된 기호라는거지. 좌우측 영역에서 바꿀 필요는 없다는 말이야. [Image #1] 여기를 보면 P기계, k 기계상각비 라고 표현되는데 경쟁사에서 어떻게 표현되어 있는지 확인해봐. 깔끔하잖아.
|
||||
GC000089의 단가품셈 기초의 항목을 내가 하나 삭제했음. 되돌림 했는데 되돌림이 안됨. 확인 후 개선하고 데이터도 원복해줘.
|
||||
|
||||
## 요구 859 (11-15 · 사용자 · 추가 업무 — ① 수식 보기 왼쪽 「표 값」 항목과 아래 인력 줄에 표 선택지가 중복으로 보임 · 없어도 됨(그림 요구859_표값_인력줄_표선택지_중복.png) ② 굴착기 항목이 왼쪽에 품번(0201-0100)으로 나오고 오른쪽 산출근거에 계산 값이 없음(—) · 괜찮은지 확인(그림 요구859_굴착기_품번_값없음.png))
|
||||
|
||||
추가 업무
|
||||
[Image #1]1. 여기에서 표 값에 항목과 아래 인력 항목에 중복의로 표 선택지를 보여주고 있음. 내용은 업어도 될듯.
|
||||
2. [Image #2] 여기에서 좌측 굴착기 항목이 품번으로 나오고 우측에 계산 값이 없는데 괜찮은 건지 확인
|
||||
|
||||
## 요구 860 (11-15 · 사용자 · ① 기호 표기 안 2(짧은 한글 이름)로 진행 · 보고 판단 ② 기계 조종원 1/8 = 8 시간 기준 — 품셈에서 해당 내용 조사(기계 부분 노임이 낮게 나옴) ③ 일위대가 불러오기에서 경쟁사 단가와 우리 단가화 비교 시 재료 구매 단위 차이로 값이 완전히 달라지는 경우 조사)
|
||||
|
||||
1. 안2로 진행하고 보고 판단할께.
|
||||
2. 우리기계의 1/8이 결국은 8시간 기준으로 했다는건데 품셈에서 해당내용을 좀 찾아봐. 대체로 기계부분의 노임 계산이 좀 낮게 나오고 있음. 조사
|
||||
3. 일위대가 불러오기에서 경쟁사 단가와 우리단가화를 비교해보면 재료의 구매 단위 차이로 인해 값이 완전히 달라지는 경우가 있음. 조사
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 37 KiB |
@@ -16,6 +16,7 @@ import math
|
||||
from decimal import ROUND_DOWN, ROUND_HALF_UP, ROUND_UP, Decimal, InvalidOperation
|
||||
|
||||
import master_doc as md
|
||||
import master_doc_cover as mcv
|
||||
|
||||
COSTS, BUNDLE = md.COSTS, md.BUNDLE
|
||||
EPS = 1e-7 # B09_Estimation_Calc_Base.EPS 와 같음
|
||||
@@ -79,8 +80,9 @@ def number(v):
|
||||
|
||||
# ── 셈 한 벌 ──────────────────────────────────────────────────────────
|
||||
class _Run:
|
||||
def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict):
|
||||
def __init__(self, doc: dict, links, given: dict | None, stack: tuple, memo: dict, cover=None):
|
||||
self.doc, self.links, self.stack, self.memo = doc, links, stack, memo
|
||||
self.cover = cover # 기계 덮음(836-1) — 맨 위 문서 칸 · 부름에 얹음
|
||||
self.given = given or {}
|
||||
self.vars = {md.var_id(v): v for v in doc.get("변수") or []}
|
||||
self.lines = {str(x.get("기호") or ""): x for x in doc.get("식") or []}
|
||||
@@ -296,7 +298,10 @@ class _Run:
|
||||
given = payload.get("입력") or {}
|
||||
memo_key = (key, tuple(sorted((k, plain_text(v)) for k, v in given.items())))
|
||||
if memo_key not in self.memo:
|
||||
self.memo[memo_key] = calc(doc, self.links, given, (*self.stack, key), self.memo)
|
||||
doc = mcv.cover_doc(doc, given, self.cover)
|
||||
self.memo[memo_key] = calc(
|
||||
doc, self.links, given, (*self.stack, key), self.memo, self.cover
|
||||
)
|
||||
got = self.memo[memo_key]
|
||||
if not got["셈됨"]: # 값 없음(결과 빈 입력 · 표 줄 없음) = 빈 값 · 그 밖(표지) = 멈춤
|
||||
why = got.get("까닭") or " · ".join(m["글"] for m in got["표지"])
|
||||
@@ -505,14 +510,19 @@ def rules_of(doc: dict) -> dict:
|
||||
|
||||
|
||||
def calc(
|
||||
doc: dict, links, given: dict | None = None, stack: tuple = (), memo: dict | None = None
|
||||
doc: dict,
|
||||
links,
|
||||
given: dict | None = None,
|
||||
stack: tuple = (),
|
||||
memo: dict | None = None,
|
||||
cover: dict | None = None,
|
||||
) -> dict:
|
||||
"""수식 문서 셈 → {값, 빈값, 줄, 노무비 · 재료비 · 경비 · 계 | 결과,
|
||||
묶음, 머리, 단계?, 표지, 셈됨, 끝수}.
|
||||
given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · 예외 없음."""
|
||||
given = {변수 열쇠: 값}(단가산출 부름이 넘긴 입력) · cover = 기계 덮음(없음 = 문서 칸) · 예외 없음."""
|
||||
memo = {} if memo is None else memo
|
||||
stack = stack or ((str(doc["키"]),) if doc.get("키") else ()) # 제 키 — 저를 부르면 순환
|
||||
run = _Run(doc, links, given, stack, memo)
|
||||
run = _Run(doc, links, given, stack, memo, mcv.cover_of(doc) if cover is None else cover)
|
||||
for one in md.check(doc):
|
||||
if one["갈래"] in ("기호 꼴", "연결 꼴", "금액 줄", "끝수", "겹침"):
|
||||
run.marks.append(one)
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""기계 덮음 (PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 단가산출 안 기계 내부 값 고침.
|
||||
|
||||
자리 = 문서 칸 `기계덮음` {기계 입력 글: {값?: {칸: 값}, 조종원?: [{키, 인}], 잡품?: [{줄? | 키?, 이름?, 수량?, 율}]}}
|
||||
기계 입력 글 = 부름 입력 `기계` 값(따옴표 뗌) — 같은 기계 줄 여럿 = 한 벌 · 원본과 같은 칸 = 없음(`prune`).
|
||||
셈 규칙(`cover_doc`) = 계산기(`master_doc_calc.calc`)가 기계 식을 부를 때 그 기계 덮음을 얹은 문서로 셈 —
|
||||
덮음 없음 = 문서 그대로(금액 전후 같음).
|
||||
파이썬 · TS 짝 — 짝 = `common_util/common_util_formula_doc_cover.ts` · 거울 시험
|
||||
`resources/tester/test_836_1_machine_cover.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy as cp
|
||||
import json
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
import master_doc as md
|
||||
|
||||
COVER = "기계덮음"
|
||||
NOT_VALUE = (
|
||||
"조종원",
|
||||
"조종원직종",
|
||||
"잡재료율",
|
||||
) # 값 칸에서 빼는 기계 칸 — 줄(조종원 · 잡품)로 다룸
|
||||
HOUR = "Q_시간환산"
|
||||
_HOUR_VARS = [
|
||||
{"열쇠": "Q_분자^표", "기호": "Q", "아래": "분자", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분자",
|
||||
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분자"}},
|
||||
{"열쇠": "Q_분모^표", "기호": "Q", "아래": "분모", "위": "표", "이름": "건설기계 조종원 노임 시간 환산 계수.분모",
|
||||
"단위": "", "연결": {"갈래": "표", "표": "CC000336", "조건": {"구분": "'시간환산'"}, "칸": "분모"}},
|
||||
{"열쇠": "시간환산", "기호": "Q", "아래": "시간환산", "위": "", "이름": "시간환산", "단위": "", "연결": {"갈래": "식"}},
|
||||
] # fmt: skip
|
||||
_HOUR_LINE = {"기호": HOUR, "식": "Q_분자^표 ÷ Q_분모^표"}
|
||||
MAX_ROWS = 30
|
||||
|
||||
|
||||
def _plain(n) -> str:
|
||||
d = Decimal(str(n)).normalize()
|
||||
return format(d, "f")
|
||||
|
||||
|
||||
def cover_of(doc) -> dict:
|
||||
got = doc.get(COVER) if isinstance(doc, dict) else None
|
||||
return got if isinstance(got, dict) else {}
|
||||
|
||||
|
||||
def machine_key(given) -> str:
|
||||
"""부름 입력 → 기계 열쇠(따옴표 뗌) · 기계 입력 없음 = ""."""
|
||||
v = (given or {}).get("기계")
|
||||
if v is None:
|
||||
return ""
|
||||
t = str(v).strip()
|
||||
return t[1:-1] if len(t) >= 2 and t[0] == "'" and t[-1] == "'" else t
|
||||
|
||||
|
||||
def _names(text) -> list[str]:
|
||||
if text is None or str(text).strip() == "":
|
||||
return []
|
||||
try:
|
||||
return md.names(md.parse(text))
|
||||
except md.DocError:
|
||||
return []
|
||||
|
||||
|
||||
def parts_of(doc: dict) -> dict:
|
||||
"""기계 식 짜임 — {fields [{칸, ids}], crew [줄 차례], crew_qty {차례: 기호}, misc, rate, base}."""
|
||||
eq: dict[str, str] = {}
|
||||
for v in doc.get("변수") or []:
|
||||
link = v.get("연결") or {}
|
||||
key = str(link.get("키") or "")
|
||||
if (
|
||||
link.get("갈래") == "요소"
|
||||
and key.startswith("EQ:")
|
||||
and "{S_기계}" in key
|
||||
and link.get("칸")
|
||||
):
|
||||
eq[md.var_id(v)] = link["칸"]
|
||||
fields: list[dict] = []
|
||||
for id_, col in eq.items():
|
||||
if col in NOT_VALUE:
|
||||
continue
|
||||
at = next((f for f in fields if f["칸"] == col), None)
|
||||
if at:
|
||||
at["ids"].append(id_)
|
||||
else:
|
||||
fields.append({"칸": col, "ids": [id_]})
|
||||
lines = doc.get("금액") or []
|
||||
crew, crew_qty, misc, rate = [], {}, None, None
|
||||
for i, line in enumerate(lines):
|
||||
q = next((n for n in _names(line.get("수량")) if eq.get(n) == "조종원"), None)
|
||||
if line.get("비목") == "노무비" and q:
|
||||
crew.append(i)
|
||||
crew_qty[i] = q
|
||||
p = _names(line.get("단가"))
|
||||
other = [n for n in p if n != "재료비"]
|
||||
if misc is None and line.get("비목") == "재료비" and "재료비" in p and len(other) == 1:
|
||||
misc, rate = i, other[0]
|
||||
base = (
|
||||
[]
|
||||
if misc is None
|
||||
else [
|
||||
i
|
||||
for i, x in enumerate(lines)
|
||||
if i < misc and x.get("비목") == "재료비" and not x.get("합밖")
|
||||
]
|
||||
)
|
||||
return {"fields": fields, "crew": crew, "crew_qty": crew_qty, "misc": misc, "rate": rate, "base": base} # fmt: skip
|
||||
|
||||
|
||||
def _num(v):
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)):
|
||||
return None
|
||||
return v
|
||||
|
||||
|
||||
def _fill_key(text: str, vals: dict) -> str:
|
||||
def one(m):
|
||||
v = vals.get(m.group(1))
|
||||
return _plain(v) if _num(v) is not None else str("" if v is None else v)
|
||||
|
||||
return re.sub(r"\{([^{}]+)\}", one, text)
|
||||
|
||||
|
||||
def defaults_of(doc: dict, vals: dict) -> dict:
|
||||
"""원본(덮음 없음) 기본값 — `vals` = 그 기계로 셈한 원본 답 `값`."""
|
||||
parts = parts_of(doc)
|
||||
lines = doc.get("금액") or []
|
||||
vars_ = {md.var_id(v): v for v in doc.get("변수") or []}
|
||||
values = {}
|
||||
for f in parts["fields"]:
|
||||
v = vals.get(f["ids"][0])
|
||||
if _num(v) is not None or isinstance(v, str):
|
||||
values[f["칸"]] = v
|
||||
crew = []
|
||||
for i in parts["crew"]:
|
||||
n = _num(vals.get(parts["crew_qty"][i]))
|
||||
sym = (_names(lines[i].get("단가")) or [""])[0]
|
||||
key = str(((vars_.get(sym) or {}).get("연결") or {}).get("키") or "")
|
||||
if n and key:
|
||||
crew.append({"키": _fill_key(key, vals), "인": n})
|
||||
r = _num(vals.get(parts["rate"])) if parts["rate"] else None
|
||||
misc = [{"줄": str(lines[i].get("이름") or ""), "율": r} for i in parts["base"]] if r else []
|
||||
return {"값": values, "조종원": crew, "잡품": misc}
|
||||
|
||||
|
||||
def _canon(v) -> str:
|
||||
def norm(x):
|
||||
if isinstance(x, dict):
|
||||
return {k: norm(y) for k, y in sorted(x.items()) if y not in (None, "")}
|
||||
if isinstance(x, list):
|
||||
return [norm(y) for y in x]
|
||||
if _num(x) is not None:
|
||||
return float(f"{float(x):.12g}")
|
||||
return x
|
||||
|
||||
return json.dumps(norm(v), ensure_ascii=False, sort_keys=True)
|
||||
|
||||
|
||||
def _same(a, b) -> bool:
|
||||
if _num(a) is not None and _num(b) is not None:
|
||||
a, b = float(a), float(b)
|
||||
return abs(a - b) <= 1e-9 * max(1.0, abs(a), abs(b))
|
||||
return str(a) == str(b)
|
||||
|
||||
|
||||
def prune(entry: dict, base: dict) -> dict | None:
|
||||
"""원본과 같은 칸 · 줄을 뺀 덮음(바꿨다 되돌림 = 안 고침) · 다 빠지면 None."""
|
||||
out: dict = {}
|
||||
vals = {
|
||||
k: v
|
||||
for k, v in (entry.get("값") or {}).items()
|
||||
if v not in (None, "") and not (k in base["값"] and _same(v, base["값"][k]))
|
||||
}
|
||||
if vals:
|
||||
out["값"] = vals
|
||||
for part in ("조종원", "잡품"):
|
||||
if entry.get(part) is not None and _canon(entry[part]) != _canon(base[part]):
|
||||
out[part] = entry[part]
|
||||
return out or None
|
||||
|
||||
|
||||
# ── 셈 규칙 ───────────────────────────────────────────────────────────
|
||||
def _add_var(doc: dict, sign: str, low: str, name: str, key: str) -> str:
|
||||
have = {md.var_id(v) for v in doc.get("변수") or []} | {
|
||||
str(x.get("기호") or "") for x in doc.get("식") or []
|
||||
}
|
||||
n = 1
|
||||
while md.sym_id(sign, f"{low}{n}") in have:
|
||||
n += 1
|
||||
under = f"{low}{n}"
|
||||
id_ = md.sym_id(sign, under)
|
||||
doc.setdefault("변수", []).append(
|
||||
{"열쇠": id_, "기호": sign, "아래": under, "위": "", "이름": name, "단위": "",
|
||||
"연결": {"갈래": "요소", "키": key}}
|
||||
) # fmt: skip
|
||||
return id_
|
||||
|
||||
|
||||
def _crew_lines(doc: dict, rows: list) -> list[dict]:
|
||||
have = any(md.var_id(v) == HOUR for v in doc.get("변수") or []) or any(
|
||||
x.get("기호") == HOUR for x in doc.get("식") or []
|
||||
)
|
||||
if rows and not have:
|
||||
ids = {md.var_id(v) for v in doc.get("변수") or []}
|
||||
doc.setdefault("변수", []).extend(
|
||||
cp.deepcopy(v) for v in _HOUR_VARS if md.var_id(v) not in ids
|
||||
)
|
||||
doc.setdefault("식", []).append(dict(_HOUR_LINE))
|
||||
return [
|
||||
{"이름": "조종원", "단위": "인", "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
|
||||
"단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])}
|
||||
for r in rows
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
def _misc_lines(doc: dict, rows: list, lines: list) -> list[dict]:
|
||||
out = []
|
||||
for r in rows:
|
||||
label = r.get("이름") or ""
|
||||
if r.get("키"):
|
||||
price = _add_var(doc, "P", "잡품", label or r["키"], r["키"])
|
||||
qty = _plain(r["수량"] if r.get("수량") is not None else 1)
|
||||
else:
|
||||
b = next((x for x in lines if str(x.get("이름") or "") == r.get("줄")), None)
|
||||
if not b or not b.get("단가"):
|
||||
continue
|
||||
price = str(b["단가"])
|
||||
qty = _plain(r["수량"]) if r.get("수량") is not None else f"({b.get('수량') or 1})"
|
||||
label = label or str(b.get("이름") or "")
|
||||
out.append(
|
||||
{"이름": "잡품", "규격": label, "단위": "", "비목": "재료비",
|
||||
"수량": f"{qty} × {_plain(r['율'])} ÷ 100", "단가": price}
|
||||
) # fmt: skip
|
||||
return out
|
||||
|
||||
|
||||
def cover_doc(doc: dict, given, cover) -> dict:
|
||||
"""기계 식 문서에 그 기계 덮음을 얹음(새 문서) — 덮음 없음 = 그대로(같은 물건)."""
|
||||
key = machine_key(given)
|
||||
entry = (cover or {}).get(key) if key else None
|
||||
if not entry:
|
||||
return doc
|
||||
out = cp.deepcopy(doc)
|
||||
out.pop("쌓기", None) # 금액 차례가 바뀜 — 기본 차림
|
||||
parts = parts_of(out)
|
||||
values = entry.get("값") or {}
|
||||
for f in parts["fields"]:
|
||||
if f["칸"] in values:
|
||||
for v in out.get("변수") or []:
|
||||
if md.var_id(v) in f["ids"]:
|
||||
v["연결"] = {"갈래": "숫자", "값": values[f["칸"]]}
|
||||
lines = out.get("금액") or []
|
||||
crew = _crew_lines(out, entry["조종원"]) if entry.get("조종원") is not None else None
|
||||
want = entry.get("잡품")
|
||||
misc = (
|
||||
_misc_lines(out, want, lines)
|
||||
if want is not None and (parts["misc"] is not None or want)
|
||||
else None
|
||||
)
|
||||
gone = set(parts["crew"] if crew is not None else []) | (
|
||||
{parts["misc"]} if misc is not None and parts["misc"] is not None else set()
|
||||
)
|
||||
tail = next((i for i, x in enumerate(lines) if x.get("비목") == "경비"), len(lines))
|
||||
crew_at = parts["crew"][0] if parts["crew"] else tail
|
||||
misc_at = parts["misc"] if parts["misc"] is not None else tail
|
||||
new = []
|
||||
for i in range(len(lines) + 1):
|
||||
if crew is not None and i == crew_at:
|
||||
new += crew
|
||||
if misc is not None and i == misc_at:
|
||||
new += misc
|
||||
if i < len(lines) and i not in gone:
|
||||
new.append(lines[i])
|
||||
out["금액"] = new
|
||||
return out
|
||||
|
||||
|
||||
# ── 서버 검사 · 정리 ──────────────────────────────────────────────────
|
||||
def _nonneg(v, where: str):
|
||||
if isinstance(v, bool) or not isinstance(v, (int, float, Decimal)) or v < 0:
|
||||
raise ValueError(f"{where} — 0 이상 수")
|
||||
return v
|
||||
|
||||
|
||||
def clean(raw) -> dict:
|
||||
"""몸 검사 — 틀리면 ValueError(까닭) · 빈 칸 · 빈 기계 = 뺌."""
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"{COVER} — 묶음 아님")
|
||||
out: dict = {}
|
||||
for key, entry in raw.items():
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError(f"{COVER} 「{key}」 — 묶음 아님")
|
||||
one: dict = {}
|
||||
values = entry.get("값")
|
||||
if values is not None:
|
||||
if not isinstance(values, dict):
|
||||
raise ValueError(f"{COVER} 「{key}」 값 — 묶음 아님")
|
||||
got = {}
|
||||
for col, v in values.items():
|
||||
if v in (None, ""):
|
||||
continue
|
||||
if col in NOT_VALUE:
|
||||
raise ValueError(f"{COVER} 「{key}」 값 「{col}」 — 줄로 고침")
|
||||
got[col] = v.strip() if isinstance(v, str) else _nonneg(v, f"「{key}」 {col}")
|
||||
if got:
|
||||
one["값"] = got
|
||||
for part, need in (("조종원", ("키", "인")), ("잡품", ("율",))):
|
||||
rows = entry.get(part)
|
||||
if rows is None:
|
||||
continue
|
||||
if not isinstance(rows, list) or len(rows) > MAX_ROWS:
|
||||
raise ValueError(f"{COVER} 「{key}」 {part} — 목록({MAX_ROWS} 줄까지)")
|
||||
for r in rows:
|
||||
if not isinstance(r, dict) or any(r.get(k) in (None, "") for k in need):
|
||||
raise ValueError(f"{COVER} 「{key}」 {part} 줄 — {' · '.join(need)} 필요")
|
||||
for k in ("인", "율", "수량"):
|
||||
if r.get(k) is not None:
|
||||
_nonneg(r[k], f"「{key}」 {part} {k}")
|
||||
if part == "잡품" and not (r.get("줄") or r.get("키")):
|
||||
raise ValueError(f"{COVER} 「{key}」 잡품 줄 — 재료(키) 또는 줄 필요")
|
||||
one[part] = rows
|
||||
if one:
|
||||
out[str(key)] = one
|
||||
return out
|
||||
|
||||
|
||||
def prune_doc(doc: dict, links) -> dict:
|
||||
"""서버 정리 — 덮음을 검사하고 원본과 같은 칸 · 줄 · 기계를 뺀 문서(새 문서 · 덮음 없음 = 그대로).
|
||||
원본 = 덮음 뺀 문서 셈의 부름 메모(그 기계 식 · 입력 · 원본 답)."""
|
||||
import master_doc_calc as mc
|
||||
|
||||
cover = clean(doc.get(COVER))
|
||||
rest = {k: v for k, v in doc.items() if k != COVER}
|
||||
if not cover:
|
||||
return rest
|
||||
memo: dict = {}
|
||||
mc.calc(rest, links, None, (), memo)
|
||||
kept: dict = {} # 문서가 안 부르는 기계 덮음 = 뺌
|
||||
for (key, given), ans in memo.items():
|
||||
m = machine_key(dict(given))
|
||||
sub = links.doc(key) if m in cover and m not in kept else None
|
||||
if sub and (one := prune(cover[m], defaults_of(sub, ans.get("값") or {}))):
|
||||
kept[m] = one
|
||||
return {**rest, COVER: kept} if kept else rest
|
||||
@@ -0,0 +1,63 @@
|
||||
/* 836-1 기계 덮음 거울 헬퍼 — common_util_formula_doc_cover.ts · calc 를 그 자리에서 트랜스파일해 Node 로 돌림.
|
||||
* stdin = {문서들: {키: 문서}, 대상: [{문서, 덮음?}], 기록: {열쇠: {값 | 빈 | 끊김}}, 짜임: [{문서, 입력, 덮음, 값}]}
|
||||
* stdout = {답: [calcDoc 답], 짜임: [{얹음: coverDoc, 기본: defaultsOf, 정리: prune}]}
|
||||
* 기록 열쇠 = 갈래 + "|" + 값 글(파이썬 `_canon` 과 같은 꼴 · helper_802_1 과 같음). */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const cache = new Map();
|
||||
function load(at) {
|
||||
if (cache.has(at)) return cache.get(at);
|
||||
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(at, box.exports);
|
||||
const need = (p) =>
|
||||
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, need);
|
||||
return box.exports;
|
||||
}
|
||||
const root = path.join(__dirname, "..", "..", "common_util");
|
||||
const calc = load(path.join(root, "common_util_formula_doc_calc.ts"));
|
||||
const cover = load(path.join(root, "common_util_formula_doc_cover.ts"));
|
||||
|
||||
function canon(v) {
|
||||
if (typeof v === "number") return calc.plainText(v);
|
||||
if (Array.isArray(v)) return v.map(canon);
|
||||
if (v && typeof v === "object") {
|
||||
const out = {};
|
||||
for (const k of Object.keys(v).sort()) out[k] = canon(v[k]);
|
||||
return out;
|
||||
}
|
||||
return v;
|
||||
}
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const record = input.기록 || {};
|
||||
function answer(kind, p) {
|
||||
const k = `${kind}|${JSON.stringify(canon(p))}`;
|
||||
if (!(k in record)) throw new calc.Broken(`기록 없음 ${k}`);
|
||||
const got = record[k];
|
||||
if ("빈" in got) throw new calc.Gap(got.빈);
|
||||
if ("끊김" in got) throw new calc.Broken(got.끊김);
|
||||
return got.값;
|
||||
}
|
||||
const links = {
|
||||
element: (p) => answer("요소", p),
|
||||
table: (p) => answer("표", p),
|
||||
doc: (key) => (input.문서들 || {})[key] || null,
|
||||
};
|
||||
const out = { 답: [], 짜임: [] };
|
||||
for (const one of input.대상 || [])
|
||||
out.답.push(calc.calcDoc(one.덮음 ? { ...one.문서, 기계덮음: one.덮음 } : one.문서, links));
|
||||
for (const one of input.짜임 || []) {
|
||||
const base = cover.defaultsOf(one.문서, one.값);
|
||||
const key = cover.machineKey(one.입력);
|
||||
out.짜임.push({
|
||||
얹음: cover.coverDoc(one.문서, one.입력, one.덮음),
|
||||
기본: base,
|
||||
정리: one.덮음[key] ? cover.prune(one.덮음[key], base) : null,
|
||||
});
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -23,6 +23,15 @@ from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
|
||||
MAN = "LB000002" # 보통인부
|
||||
|
||||
|
||||
@@ -32,6 +32,15 @@ from resources.tester.test_795_1_sk_copy import (
|
||||
)
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
KEY = "GC000211" # 합판거푸집 — 중간 시공량 · 형틀목공 · 보통인부 · 호표 0 = 형틀목공
|
||||
DOUBLE = [{"할일": "식", "자리": "호표:0", "식": "형틀목공 × 2", "까닭": "sub2임시 시험"}]
|
||||
|
||||
|
||||
@@ -28,6 +28,15 @@ from resources.tester.test_795_2_sc_copy import LB, PCT, _double, _labor, _sc
|
||||
from resources.tester.test_795_2_sc_copy import _calc as _sc_calc
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
FORM = "GC000211" # 합판거푸집(옵션 많음)
|
||||
BASIC = "GF000089" # 토사깍기(기계) — 굴착기 시간당 × 1 / 작업량(813-2 GC001148 지움 뒤)
|
||||
ADD = {"종류": "로직", "로직": BASIC, "수량": 2, "입력값": {}, "이름": "sub2임시 굴착기"}
|
||||
|
||||
@@ -26,6 +26,15 @@ from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음
|
||||
PCT = "GC001109" # M01 만 쓰는 SK 바탕
|
||||
NAME = "서브셋임시기계" # 찾기 기종 글(수 = 규격으로 읽음 · 창 표시는 글로)
|
||||
|
||||
@@ -22,6 +22,15 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
DIG, DIG_NO, DIG_LOGIC = "EQ000022", "0201-0100", "GC000267" # 굴착기(무한궤도) 1.0㎥
|
||||
DIG2_NO = "0201-0070" # 바꿔 끼울 굴착기
|
||||
MAN = "LB000002" # 보통인부
|
||||
|
||||
@@ -14,6 +14,8 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Import as dim
|
||||
from M02_MasterTemplete import M02_Doc_Sk as dsk
|
||||
@@ -24,6 +26,15 @@ from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
from resources.tester.test_822_1_copy_gap import _move
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
HERE = os.path.dirname(__file__)
|
||||
DIG = "GC000267" # 굴착기 시간당 경비
|
||||
NOW, NEW, NEW_EQ = "'0201-0070'", "'0201-0100'", "EQ000022" # 0.7㎥ → 1.0㎥
|
||||
|
||||
@@ -14,6 +14,8 @@ import copy
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as path
|
||||
from M02_MasterTemplete import M02_Item_Prices as prices
|
||||
@@ -36,6 +38,15 @@ from resources.tester.test_822_1_copy_gap import (
|
||||
)
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
DIG_BASE = "GC000088" # 터파기(기계) 보통토사 — 「기계」 고르기 후보를 닫힌 묶음이 다 담음
|
||||
|
||||
|
||||
|
||||
@@ -25,6 +25,15 @@ from M02_MasterTemplete import M02_MyMachine_Draft as draft
|
||||
from M02_MasterTemplete import M02_MyMachine_Router as route
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _manual(monkeypatch):
|
||||
"""836-2 — 이 시험 = 손 갱신 길(프로젝트 층 꼴 · 자동 따라옴 끔) · 자동 = test_836_2."""
|
||||
from M02_MasterTemplete import M02_Sk_AutoRenew as renew
|
||||
|
||||
monkeypatch.setattr(renew, "on", lambda: False)
|
||||
|
||||
|
||||
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ — 연료 · 조종원 있음
|
||||
NAME = "sub1임시 기계"
|
||||
USER = {"role": "USER", "company_id": 7, "user_id": 42}
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
"""836-1 기계 덮음 — 셈 규칙 · 기본값 · 정리 · 몸 검사 · 거울(파이썬 ↔ TS) 지킴 시험.
|
||||
|
||||
짝 = `resources/master_data/scripts/master_doc_cover.py` ↔ `common_util/common_util_formula_doc_cover.ts`
|
||||
(계산기 부름 자리 = `master_doc_calc` ↔ `common_util_formula_doc_calc.ts`).
|
||||
① 덮음 없음 = 문서 그대로(같은 물건 · 같은 금액) · 남의 기계 덮음 = 안 얹음.
|
||||
② 굴착기(무한궤도) 1.0㎥(0201-0100 · GC000267) — 값 · 조종원 · 잡품 덮음 = 손셈(M01 값).
|
||||
③ 조종원 없는 식에 조종원 더함(시간환산 표 더함) · 잡품 없는 식에 잡품 더함 · 연료 + 전력 식 기본 잡품 둘.
|
||||
④ 정리 — 원본과 같으면 지움 · 안 부르는 기계 지움 · 몸 검사 틀림.
|
||||
⑤ 거울 — 같은 연결 값으로 파이썬 · TS 답 · 얹은 문서 · 기본값 · 정리가 같음.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from decimal import Decimal as D
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
|
||||
|
||||
mc = importlib.import_module("master_doc_calc")
|
||||
ml = importlib.import_module("master_doc_link")
|
||||
mcv = importlib.import_module("master_doc_cover")
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
COSTS = ("노무비", "재료비", "경비", "계")
|
||||
NO = "0201-0100" # 굴착기(무한궤도) 1.0㎥ — 조사 846 1 장 견본
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def links():
|
||||
_, whole = m01.loaded()
|
||||
return ml.Links(whole)
|
||||
|
||||
|
||||
def _one(logic: str, no: str, cover: dict | None = None) -> dict:
|
||||
"""기계 한 줄 문서(1 hr) — 덮음 = 문서 칸."""
|
||||
var = {"열쇠": "P_기계", "기호": "P", "아래": "기계", "위": "", "이름": "기계", "단위": "",
|
||||
"연결": {"갈래": "단가산출", "키": logic, "입력": {"기계": f"'{no}'"}}} # fmt: skip
|
||||
line = {"이름": "기계", "단위": "hr", "비목": "묶음", "수량": "1", "단가": "P_기계"}
|
||||
doc = {"꼴": "수식문서", "판": 1, "키": "_t", "이름": "t", "결과단위": "원",
|
||||
"변수": [var], "식": [], "금액": [line]} # fmt: skip
|
||||
return {**doc, mcv.COVER: cover} if cover else doc
|
||||
|
||||
|
||||
def _money(ans):
|
||||
return {k: ans.get(k) for k in COSTS}
|
||||
|
||||
|
||||
def _first(links, logic: str) -> str:
|
||||
var = next(v for v in links.doc(logic)["변수"] if v.get("열쇠") == "기계")
|
||||
return str(var["연결"]["목록"][0])
|
||||
|
||||
|
||||
def _orig(links, logic: str, no: str) -> dict:
|
||||
memo: dict = {}
|
||||
mc.calc(_one(logic, no), links, None, (), memo)
|
||||
return next(a for (k, _), a in memo.items() if k == logic)
|
||||
|
||||
|
||||
# ── ① 덮음 없음 ────────────────────────────────────────────────────────
|
||||
def test_덮음_없음_같은_물건_같은_금액(links):
|
||||
doc = links.doc("GC000267")
|
||||
assert mcv.cover_doc(doc, {"기계": NO}, {}) is doc
|
||||
assert mcv.cover_doc(doc, {"기계": NO}, {"0201-0120": {"값": {"가격": 1}}}) is doc
|
||||
assert mcv.cover_doc(doc, {}, {NO: {"값": {"가격": 1}}}) is doc
|
||||
base = mc.calc(_one("GC000267", NO), links)
|
||||
other = mc.calc(_one("GC000267", NO, {"0201-0120": {"값": {"가격": 1}}}), links)
|
||||
assert _money(base) == _money(other)
|
||||
assert base["계"] == D(108958) or base["셈됨"] # M01 값이 바뀌면 금액은 달라도 됨(값 대조는 ②)
|
||||
|
||||
|
||||
# ── ② 손셈 ────────────────────────────────────────────────────────────
|
||||
def _cut1(x):
|
||||
return D(str(x)).quantize(D("0.1"), rounding="ROUND_DOWN")
|
||||
|
||||
|
||||
def test_굴착기_덮음_손셈(links):
|
||||
v = _orig(links, "GC000267", NO)["값"]
|
||||
price, fuel, oil = v["P_기계"], v["P_기계주연료"], v["P_자동차용경유"]
|
||||
wage, rate = v["w_기계운전사"], v["r_기계잡재료"]
|
||||
k = (v["k_기계상각비"] + v["k_기계정비비"]) * (1 + v["r_가산"] / 100) + v["k_기계관리비"]
|
||||
|
||||
def loss(p):
|
||||
return (D(p) * 1000 * k / D(10) ** 7).to_integral_value(rounding="ROUND_DOWN")
|
||||
|
||||
def total(labor, mats, exp):
|
||||
return {"노무비": int(labor), "재료비": int(mats), "경비": int(exp)}
|
||||
|
||||
fuel_amt = _cut1(fuel * oil)
|
||||
crew1 = _cut1(D(1) / 8 * wage)
|
||||
misc = _cut1(fuel_amt * rate / 100)
|
||||
want = total(crew1, fuel_amt + misc, loss(price))
|
||||
got = mc.calc(_one("GC000267", NO), links)
|
||||
assert {c: int(got[c]) for c in ("노무비", "재료비", "경비")} == want
|
||||
|
||||
def run(cover):
|
||||
a = mc.calc(_one("GC000267", NO, {NO: cover}), links)
|
||||
assert a["셈됨"], a["표지"]
|
||||
return {c: int(a[c]) for c in ("노무비", "재료비", "경비")}
|
||||
|
||||
assert run({"값": {"가격": 150000}}) == total(crew1, fuel_amt + misc, loss(150000))
|
||||
f20 = _cut1(20 * oil)
|
||||
assert run({"값": {"주연료": 20}}) == total(crew1, f20 + _cut1(f20 * rate / 100), loss(price))
|
||||
assert run({"조종원": [{"키": "LB:1048", "인": 2}]}) == total(
|
||||
_cut1(D(2) / 8 * wage), fuel_amt + misc, loss(price)
|
||||
)
|
||||
assert run({"조종원": []}) == total(0, fuel_amt + misc, loss(price))
|
||||
assert run({"잡품": [{"줄": "주연료", "율": 30}]}) == total(
|
||||
crew1, fuel_amt + _cut1(fuel * oil * 30 / 100), loss(price)
|
||||
)
|
||||
assert run({"잡품": []}) == total(crew1, fuel_amt, loss(price))
|
||||
# 다른 직종 · 사람 둘 = 두 줄
|
||||
two = run({"조종원": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 1}]})
|
||||
labor = mc.calc(_one("GC000267", NO, {NO: {"조종원": [{"키": "LB:1002", "인": 1}]}}), links)
|
||||
assert two["노무비"] == int(crew1) + int(labor["노무비"])
|
||||
|
||||
|
||||
# ── ③ 줄 더함 ─────────────────────────────────────────────────────────
|
||||
def test_조종원_없는_식에_조종원_잡품_없는_식에_잡품(links):
|
||||
no = _first(links, "GC000272")
|
||||
base = mc.calc(_one("GC000272", no), links)
|
||||
got = mc.calc(_one("GC000272", no, {no: {"조종원": [{"키": "LB:1048", "인": 1}]}}), links)
|
||||
assert got["셈됨"], got["표지"]
|
||||
assert got["노무비"] > 0 and got["재료비"] == base["재료비"] and got["경비"] == base["경비"]
|
||||
no3 = _first(links, "GC000273")
|
||||
base3 = mc.calc(_one("GC000273", no3), links)
|
||||
got3 = mc.calc(
|
||||
_one("GC000273", no3, {no3: {"잡품": [{"키": "LB:1048", "수량": 1, "율": 10}]}}), links
|
||||
)
|
||||
assert got3["셈됨"] and got3["재료비"] > base3["재료비"] == 0
|
||||
|
||||
|
||||
def test_연료_전력_식_기본_잡품_둘(links):
|
||||
doc = links.doc("GC001200")
|
||||
parts = mcv.parts_of(doc)
|
||||
assert parts["misc"] is not None and len(parts["base"]) == 2
|
||||
|
||||
|
||||
# ── ④ 정리 · 검사 ─────────────────────────────────────────────────────
|
||||
def test_원본과_같으면_지움_안_부르는_기계_지움(links):
|
||||
v = _orig(links, "GC000267", NO)["값"]
|
||||
same = {NO: {"값": {"가격": v["P_기계"]}, "조종원": [{"키": "LB:1048", "인": 1}],
|
||||
"잡품": [{"줄": "주연료", "율": v["r_기계잡재료"]}]}} # fmt: skip
|
||||
assert mcv.COVER not in mcv.prune_doc(_one("GC000267", NO, same), links)
|
||||
mixed = {NO: {"값": {"가격": v["P_기계"], "주연료": 20}}, "9999-9999": {"값": {"가격": 1}}}
|
||||
got = mcv.prune_doc(_one("GC000267", NO, mixed), links)[mcv.COVER]
|
||||
assert got == {NO: {"값": {"주연료": 20}}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
[],
|
||||
{NO: 1},
|
||||
{NO: {"값": {"가격": -1}}},
|
||||
{NO: {"값": {"조종원": 2}}},
|
||||
{NO: {"조종원": [{"키": "", "인": 1}]}},
|
||||
{NO: {"조종원": [{"키": "LB:1048", "인": -1}]}},
|
||||
{NO: {"잡품": [{"율": 10}]}},
|
||||
{NO: {"잡품": [{"줄": "주연료", "율": "x"}]}},
|
||||
],
|
||||
)
|
||||
def test_몸_검사_틀림(bad):
|
||||
with pytest.raises(ValueError):
|
||||
mcv.clean(bad)
|
||||
|
||||
|
||||
def test_몸_검사_빈_칸_뺌():
|
||||
assert mcv.clean({NO: {"값": {"가격": ""}}}) == {}
|
||||
assert mcv.clean({NO: {"값": {"가격": 0}}}) == {NO: {"값": {"가격": 0}}}
|
||||
|
||||
|
||||
# ── ⑤ 거울 ────────────────────────────────────────────────────────────
|
||||
def _canon(v):
|
||||
if isinstance(v, (D, int, float)) and not isinstance(v, bool):
|
||||
return mc.plain_text(D(str(v)))
|
||||
if isinstance(v, list):
|
||||
return [_canon(x) for x in v]
|
||||
if isinstance(v, dict):
|
||||
return {k: _canon(v[k]) for k in sorted(v)}
|
||||
return v
|
||||
|
||||
|
||||
class Rec:
|
||||
def __init__(self, links):
|
||||
self.links, self.log = links, {}
|
||||
|
||||
def _keep(self, kind, p, fn):
|
||||
key = f"{kind}|{json.dumps(_canon(p), ensure_ascii=False, separators=(',', ':'))}"
|
||||
try:
|
||||
got = fn(p)
|
||||
except mc.Gap as e:
|
||||
self.log[key] = {"빈": str(e)}
|
||||
raise
|
||||
except mc.Broken as e:
|
||||
self.log[key] = {"끊김": str(e)}
|
||||
raise
|
||||
self.log[key] = {"값": got}
|
||||
return got
|
||||
|
||||
def element(self, p):
|
||||
return self._keep("요소", p, self.links.element)
|
||||
|
||||
def table(self, p):
|
||||
return self._keep("표", p, self.links.table)
|
||||
|
||||
def doc(self, key):
|
||||
return self.links.doc(key)
|
||||
|
||||
|
||||
def _same(a, b, where=""):
|
||||
if isinstance(a, dict):
|
||||
assert isinstance(b, dict) and set(a) == set(b), (where, sorted(a), sorted(b or {}))
|
||||
for k in a:
|
||||
_same(a[k], b[k], f"{where}.{k}")
|
||||
elif isinstance(a, list):
|
||||
assert isinstance(b, list) and len(a) == len(b), where
|
||||
for i, (x, y) in enumerate(zip(a, b)):
|
||||
_same(x, y, f"{where}[{i}]")
|
||||
elif isinstance(a, (D, int, float)) and not isinstance(a, bool):
|
||||
assert isinstance(b, (int, float)) and not isinstance(b, bool), (where, a, b)
|
||||
assert float(a) == pytest.approx(b, rel=1e-9, abs=1e-9), (where, a, b)
|
||||
else:
|
||||
assert a == b, (where, a, b)
|
||||
|
||||
|
||||
COVERS = [
|
||||
None,
|
||||
{"값": {"가격": 150000, "주연료종류": "휘발유"}},
|
||||
{"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]},
|
||||
{"조종원": []},
|
||||
{"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]},
|
||||
{"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]},
|
||||
]
|
||||
|
||||
|
||||
def test_거울_파이썬_TS_같은_답(links):
|
||||
rec = Rec(links)
|
||||
picks = [("GC000267", NO)] + [
|
||||
(k, _first(links, k))
|
||||
for k in ("GC000272", "GC000273", "GC000275", "GC000276", "GC001196", "GC001197")
|
||||
]
|
||||
cases, want, shapes, shape_want = [], [], [], []
|
||||
for logic, no in picks:
|
||||
orig = _orig(Rec(links), logic, no) # 기록에 안 섞임(원본 셈은 아래에서 다시)
|
||||
for one in COVERS:
|
||||
cover = {no: one} if one else None
|
||||
doc = _one(logic, no)
|
||||
cases.append({"문서": doc, "덮음": cover})
|
||||
want.append(mc.calc({**doc, mcv.COVER: cover} if cover else doc, rec))
|
||||
if cover:
|
||||
base = mcv.defaults_of(links.doc(logic), orig["값"])
|
||||
shapes.append({"문서": links.doc(logic), "입력": {"기계": no}, "덮음": cover,
|
||||
"값": orig["값"]}) # fmt: skip
|
||||
shape_want.append({"얹음": mcv.cover_doc(links.doc(logic), {"기계": no}, cover),
|
||||
"기본": base, "정리": mcv.prune(cover[no], base)}) # fmt: skip
|
||||
docs = {k: links.doc(k) for k, _ in picks}
|
||||
payload = {"문서들": docs, "대상": cases, "기록": rec.log, "짜임": shapes}
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_836_1_machine_cover.cjs")],
|
||||
input=json.dumps(ml._norm(payload), ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
got = json.loads(proc.stdout)
|
||||
for i, (a, b) in enumerate(zip(want, got["답"])):
|
||||
_same(ml._norm(_money(a)), _money(b), f"답{i}")
|
||||
_same(ml._norm(a["값"]), b["값"], f"값{i}")
|
||||
assert len(got["짜임"]) == len(shape_want)
|
||||
for i, (a, b) in enumerate(zip(shape_want, got["짜임"])):
|
||||
_same(ml._norm(a), b, f"짜임{i}")
|
||||
|
||||
|
||||
# ── ⑥ 서버 저장 길(임시 층 · 수식 길) ────────────────────────────────────
|
||||
@pytest.fixture
|
||||
def world(tmp_path, monkeypatch):
|
||||
from resources.tester.test_m02_labor_ref import world as make
|
||||
|
||||
return make.__wrapped__(tmp_path, monkeypatch)
|
||||
|
||||
|
||||
def test_저장_다시_열기_되돌리면_덮음_없음(world):
|
||||
import copy as cp
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as path
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
me = world["personal"]
|
||||
|
||||
def at(fn, *args):
|
||||
with store.at(me):
|
||||
return fn(*args)
|
||||
|
||||
key = at(
|
||||
lambda: refs.create("sangeun", {"이름": "sub3임시 덮음", "단위": "시간", "더한줄": []})
|
||||
)
|
||||
key = key["이름"]
|
||||
got = at(path.read, key)
|
||||
doc = cp.deepcopy(got["문서"])
|
||||
link = {"갈래": "단가산출", "키": "GC000267", "입력": {"기계": f"'{NO}'"}}
|
||||
for s in ("기계", "기계둘"):
|
||||
doc.setdefault("변수", []).append(
|
||||
{"기호": "P", "아래": s, "위": "", "이름": s, "단위": "", "연결": link}
|
||||
)
|
||||
doc.setdefault("금액", []).append(
|
||||
{"이름": s, "단위": "hr", "비목": "묶음", "수량": "1", "단가": f"P_{s}"}
|
||||
)
|
||||
at(path.save, key, {"문서": doc, "판": got["판"]})
|
||||
base = at(path.read, key)
|
||||
was = base["셈"]["계"]
|
||||
one = base["셈"]["줄"][0]["금액"]
|
||||
doc = cp.deepcopy(base["문서"])
|
||||
doc[mcv.COVER] = {NO: {"값": {"가격": 150000}}, "9999-9999": {"값": {"가격": 1}}}
|
||||
after = at(path.save, key, {"문서": doc, "판": base["판"]})
|
||||
assert after["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}} # 안 부르는 기계 = 뺌
|
||||
lines = after["셈"]["줄"]
|
||||
assert lines[0]["금액"] == lines[1]["금액"] > one # 같은 기계 두 줄 = 같이 바뀜
|
||||
again = at(path.read, key)
|
||||
assert again["문서"][mcv.COVER] == {NO: {"값": {"가격": 150000}}}
|
||||
assert again["셈"]["계"] == after["셈"]["계"] > was
|
||||
v = _orig(ml.Links(m01.loaded()[1]), "GC000267", NO)["값"]
|
||||
doc = cp.deepcopy(again["문서"])
|
||||
doc[mcv.COVER] = {NO: {"값": {"가격": v["P_기계"]}}} # 되돌림 = 원본 값
|
||||
back = at(path.save, key, {"문서": doc, "판": again["판"]})
|
||||
assert mcv.COVER not in back["문서"] and back["셈"]["계"] == was
|
||||
with store.at(me):
|
||||
file = store.read("sangeun", key)["문서"]
|
||||
assert mcv.COVER not in file["수식"]["문서"]
|
||||
doc[mcv.COVER] = {NO: {"값": {"가격": -5}}}
|
||||
with pytest.raises(store.StoreError):
|
||||
at(path.save, key, {"문서": doc, "판": back["판"]})
|
||||
@@ -0,0 +1,248 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""836-2 M01 → M02 단가산출 자동 따라옴(요구 854) — 임시 storage + M01 메모리 사본(정본 파일 안 고침).
|
||||
|
||||
㉠ 노임 · 재료 · 기계 값 바뀜 = 누르지 않고 새 금액(수식 길 열기 · 옛 셈 길 목록 · 일위대가 펼침 같은 답)
|
||||
㉡ 부른 M01 수식 문서 바뀜 = 따라옴 ㉢ 고친 문서 + 그 문서 M01 바뀜 = 다시 복사 + 알림(전 · 후 · 내 고침)
|
||||
㉣ 고침 + 다른 항목만 바뀜 = 고침 그대로 ㉤ 바꿨다 되돌림 = 안 고침(알림 없음)
|
||||
㉥ 프로젝트 층 = 그대로 ㉦ 가져온 식(우리) = 값만 · 식 그대로 · 열기 = 파일 안 고침 · 알림 [확인] = 저장.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import shutil
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Doc_Copy as dc
|
||||
from M02_MasterTemplete import M02_Doc_Sk_Path as path
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from resources.tester.test_822_1_copy_gap import (
|
||||
DIG,
|
||||
MACHINE,
|
||||
MAN,
|
||||
PCT,
|
||||
_at,
|
||||
_live,
|
||||
_move,
|
||||
_sk,
|
||||
_stack,
|
||||
_sum,
|
||||
)
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
TOOL = 2 # PCT 「공구손료 및 경장비」 금액 줄 차례
|
||||
|
||||
|
||||
def _doc_move(monkeypatch, key: str, fn) -> None:
|
||||
"""M01 수식 문서 하나 고침(메모리 · 폴더 그대로)."""
|
||||
live = dc.folder()
|
||||
new = copy.deepcopy(live[key])
|
||||
fn(new)
|
||||
fake = {**live, key: new}
|
||||
monkeypatch.setattr(dc, "folder", lambda: fake)
|
||||
|
||||
|
||||
def _rate(text: str):
|
||||
def fn(d: dict) -> None:
|
||||
d["금액"][TOOL]["단가"] = text
|
||||
|
||||
return fn
|
||||
|
||||
|
||||
def _edit(me, key: str, text: str) -> None:
|
||||
got = _at(me, path.read, key)
|
||||
doc = copy.deepcopy(got["문서"])
|
||||
doc["금액"][TOOL]["단가"] = text
|
||||
_at(me, path.save, key, {"문서": doc, "판": got["판"]})
|
||||
|
||||
|
||||
def _base(me) -> str:
|
||||
return _sk(me, 바탕={"로직": PCT}, 단위="㎡")
|
||||
|
||||
|
||||
def _labor_unit(me, key: str):
|
||||
"""일위대가 줄이 SK 참조를 펼치는 길(`Expand` → `calc.unit`) 1 단위 계."""
|
||||
from M02_MasterTemplete.M02_Labor_Expand import Expand
|
||||
|
||||
with store.at(me):
|
||||
ref = {"층": "personal", "주인": "7/42", "키": key}
|
||||
whole = _live()
|
||||
got = calc.unit(refs.read_ref(ref), whole, Expand(whole), ())
|
||||
return got["기준"]["계"] if got["기준"] else None
|
||||
|
||||
|
||||
def _list_unit(me, key: str):
|
||||
"""목록 · 옛 셈 길(`calc_doc`) 1 단위 계."""
|
||||
with store.at(me):
|
||||
got = calc.calc_doc(store.read("sangeun", key)["문서"], None, False)
|
||||
return got["1단위"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("what", ["노임", "재료", "기계"])
|
||||
def test_values_follow(world, monkeypatch, what):
|
||||
"""㉠ 값 바뀜 = 묻지 않고 새 금액 · 세 길 같은 답 · 열기 = 파일 안 고침 · 알림 없음."""
|
||||
me = world["personal"]
|
||||
key = _sk(me)
|
||||
res = {"노임": MAN, "재료": next(iter(_live().index["MT"])), "기계": DIG}[what]
|
||||
link = MACHINE if what == "기계" else {"갈래": "요소", "키": res}
|
||||
part = {"노임": "노무비", "재료": "재료비", "기계": "묶음"}[what]
|
||||
_stack(me, key, [("자원", link, part)])
|
||||
was = _sum(me, key)
|
||||
file = me / "sangeun" / f"{key}.json"
|
||||
before = file.read_bytes()
|
||||
_move(monkeypatch, res)
|
||||
got = _at(me, path.read, key)
|
||||
assert got["셈"]["계"] > was
|
||||
assert got["사본"]["자동"] is True and got["사본"]["갱신"] == []
|
||||
assert not got["사본"]["단가새값"] and not got["사본"]["M01새판"]
|
||||
unit = _list_unit(me, key)
|
||||
assert unit["계"] == pytest.approx(got["셈"]["계"])
|
||||
assert _labor_unit(me, key) == pytest.approx(got["셈"]["계"])
|
||||
assert file.read_bytes() == before # 읽기 = 작업본 안 씀
|
||||
|
||||
|
||||
def test_logic_follows(world, monkeypatch):
|
||||
"""㉡ 부른 M01 수식 문서(기계 시간당) 바뀜 = 따라옴 · 알림 없음."""
|
||||
me = world["personal"]
|
||||
key = _sk(me)
|
||||
_stack(me, key, [("기계", MACHINE, "묶음")])
|
||||
was = _sum(me, key)
|
||||
|
||||
def twice(d: dict) -> None:
|
||||
for line in d["금액"]:
|
||||
line["수량"] = f"({line.get('수량') or 1}) × 2"
|
||||
|
||||
_doc_move(monkeypatch, MACHINE["키"], twice)
|
||||
got = _at(me, path.read, key)
|
||||
assert got["셈"]["계"] > was * 1.5 # 율 줄은 누계를 받아 두 배 넘음
|
||||
assert got["사본"]["갱신"] == [] and not got["사본"]["문서새판"]
|
||||
|
||||
|
||||
def test_edited_item_recopied(world, monkeypatch):
|
||||
"""㉢ 고친 문서 + 그 M01 문서 바뀜 = 고침 지우고 다시 복사 + 알림(전 · 후 · 내 고침) ·
|
||||
[확인](저장) 뒤 알림 없음 · 따로 아님."""
|
||||
me = world["personal"]
|
||||
key = _base(me)
|
||||
plain = _sum(me, key)
|
||||
_edit(me, key, "노무비 × 5%")
|
||||
assert _sum(me, key) > plain
|
||||
_doc_move(monkeypatch, PCT, _rate("노무비 × 4%"))
|
||||
got = _at(me, path.read, key)
|
||||
labor = got["셈"]["노무비"]
|
||||
assert got["셈"]["경비"] == pytest.approx(int(labor * 0.04), abs=1)
|
||||
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 4%"
|
||||
[note] = got["사본"]["갱신"]
|
||||
assert note["키"] == PCT and note["갈래"] == "문서"
|
||||
assert any("3%" in x and "4%" in x for x in note["M01"])
|
||||
assert any("3%" in x and "5%" in x for x in note["내고침"])
|
||||
_at(me, path.save, key, {"할일": "갱신확인", "판": got["판"]})
|
||||
after = _at(me, path.read, key)
|
||||
assert after["사본"]["갱신"] == [] and after["사본"]["따로"] is False
|
||||
assert after["셈"]["계"] == got["셈"]["계"]
|
||||
|
||||
|
||||
def test_edit_kept_when_other_item_moves(world, monkeypatch):
|
||||
"""㉣ 고침 + 다른 항목(노임 값)만 바뀜 = 고침 그대로 · 값은 따라옴."""
|
||||
me = world["personal"]
|
||||
key = _base(me)
|
||||
_edit(me, key, "노무비 × 5%")
|
||||
was = _at(me, path.read, key)["셈"]
|
||||
_move(monkeypatch, MAN)
|
||||
got = _at(me, path.read, key)
|
||||
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 5%"
|
||||
assert got["사본"]["따로"] is True and got["사본"]["갱신"] == []
|
||||
assert got["셈"]["노무비"] > was["노무비"]
|
||||
|
||||
|
||||
def test_reverted_is_not_edit(world, monkeypatch):
|
||||
"""㉤ 바꿨다 되돌려 저장 = 안 고침 — M01 바뀌면 조용히 따라옴(알림 없음)."""
|
||||
me = world["personal"]
|
||||
key = _base(me)
|
||||
_edit(me, key, "노무비 × 5%")
|
||||
_edit(me, key, "노무비 × 3%")
|
||||
_doc_move(monkeypatch, PCT, _rate("노무비 × 4%"))
|
||||
got = _at(me, path.read, key)
|
||||
assert got["사본"]["갱신"] == []
|
||||
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 4%"
|
||||
|
||||
|
||||
def test_project_layer_keeps(world, monkeypatch, tmp_path):
|
||||
"""㉥ 프로젝트 층 사본 = 그대로(M01 바뀌어도 금액 같음 · 고침 그대로)."""
|
||||
me = world["personal"]
|
||||
key = _base(me)
|
||||
_edit(me, key, "노무비 × 5%")
|
||||
proj = me.parent / "p836" / "templates"
|
||||
(proj / "sangeun").mkdir(parents=True)
|
||||
shutil.copy(me / "sangeun" / f"{key}.json", proj / "sangeun" / f"{key}.json")
|
||||
with store.at(proj):
|
||||
assert refs.here()[0] == "project"
|
||||
was = _at(proj, path.read, key)
|
||||
_move(monkeypatch, MAN)
|
||||
_doc_move(monkeypatch, PCT, _rate("노무비 × 4%"))
|
||||
got = _at(proj, path.read, key)
|
||||
assert got["셈"]["계"] == was["셈"]["계"] and got["사본"]["자동"] is False
|
||||
assert got["문서"]["금액"][TOOL]["단가"] == "노무비 × 5%" and got["사본"]["갱신"] == []
|
||||
|
||||
|
||||
def test_imported_values_only(world, monkeypatch):
|
||||
"""㉦ 가져온 식 단가기준 우리 = 이음 노임만 따라옴 · 가져온 줄 식 그대로 · 엑셀 기준 = 엑셀 값."""
|
||||
from M02_MasterTemplete import M02_Sangeun_Imported as imp
|
||||
|
||||
me = world["personal"]
|
||||
man = {"종류": "인력", "이름": "보통인부", "단위": "인", "수량": "0.5", "비목": "노무비"}
|
||||
row = {
|
||||
"이름": "sub2임시 가져온",
|
||||
"결과단위": "원/㎡",
|
||||
"호표": [{**man, "단가": 165545, "이음": MAN}],
|
||||
}
|
||||
key = _at(me, lambda: imp.create(row, None, "sub2임시 가져온")["이름"])
|
||||
excel = _sum(me, key)
|
||||
doc = _at(me, lambda: store.read("sangeun", key)["문서"])
|
||||
ours = _at(me, lambda: imp.to_price(doc, "우리")["문서"])
|
||||
_at(me, lambda: store.write("sangeun", key, store.read("sangeun", key)["판"], ours))
|
||||
was = _at(me, path.read, key)
|
||||
_move(monkeypatch, MAN)
|
||||
got = _at(me, path.read, key)
|
||||
assert got["셈"]["계"] == pytest.approx(was["셈"]["계"] * 2, abs=2)
|
||||
assert got["문서"]["금액"] == was["문서"]["금액"] and got["사본"]["갱신"] == []
|
||||
back = _at(me, lambda: imp.to_price(store.read("sangeun", key)["문서"], "엑셀")["문서"])
|
||||
_at(me, lambda: store.write("sangeun", key, store.read("sangeun", key)["판"], back))
|
||||
assert _sum(me, key) == excel
|
||||
|
||||
|
||||
def _cover(me, key: str, cover: dict) -> None:
|
||||
got = _at(me, path.read, key)
|
||||
doc = {**copy.deepcopy(got["문서"]), "기계덮음": cover}
|
||||
_at(me, path.save, key, {"문서": doc, "판": got["판"]})
|
||||
|
||||
|
||||
def test_machine_cover_dropped_when_machine_moves(world, monkeypatch):
|
||||
"""㉢ 기계 덮음(836-1) + 그 기계 줄 M01 바뀜 = 덮음 지움 + 알림(전 · 후 · 내 고침 값)."""
|
||||
me = world["personal"]
|
||||
key = _sk(me)
|
||||
_stack(me, key, [("기계", MACHINE, "묶음")])
|
||||
no = MACHINE["입력"]["기계"].strip("'")
|
||||
_cover(me, key, {no: {"값": {"가격": 150000}}})
|
||||
assert _at(me, path.read, key)["문서"]["기계덮음"]
|
||||
_move(monkeypatch, DIG)
|
||||
got = _at(me, path.read, key)
|
||||
assert "기계덮음" not in got["문서"]
|
||||
[note] = got["사본"]["갱신"]
|
||||
assert note["키"] == DIG and note["갈래"] == "기계"
|
||||
assert any(x.startswith("가격 ") for x in note["M01"]) and note["내고침"] == ["가격 150000"]
|
||||
|
||||
|
||||
def test_machine_cover_kept_other_item(world, monkeypatch):
|
||||
"""㉣ 기계 덮음 + 다른 항목(바탕 문서)만 바뀜 = 덮음 그대로(문서 다시 복사 뒤에도)."""
|
||||
me = world["personal"]
|
||||
key = _base(me)
|
||||
_stack(me, key, [("기계", MACHINE, "묶음")])
|
||||
no = MACHINE["입력"]["기계"].strip("'")
|
||||
_cover(me, key, {no: {"값": {"가격": 150000}}})
|
||||
_doc_move(monkeypatch, PCT, _rate("노무비 × 4%"))
|
||||
got = _at(me, path.read, key)
|
||||
assert got["문서"]["기계덮음"] == {no: {"값": {"가격": 150000}}}
|
||||
assert [n["갈래"] for n in got["사본"]["갱신"]] == ["문서"] # 기계 줄 붙인 문서 고침
|
||||
Reference in New Issue
Block a user