- 원본 popupSNameShow: 산출근거 식의 기호를 비목 약어로, 숫자를 요율 글자로 바꿔 화면·인쇄에 표시하고 편집 잠금, 저장 값은 그대로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
300 lines
12 KiB
JavaScript
300 lines
12 KiB
JavaScript
// 원본 원가계산서 보조 계산(TfrmWongaDiget) — 기타경비 계산(popGita 모드 4, 0xd0bc80·확인 0xd09c89)과
|
||
// 산업안전보건관리비 비교(popAnjun 모드 1, 식 0xd0b298·확인 0xd09973). 요율·기초금액은 사용자가 넣는다(내장 요율표 없음).
|
||
// 기타경비: 식1 = (직접공사비 × 재료비율) + 직접노무비 + 간접노무비, 식2 = 직접공사비 × 기초금액상한율 — 작은 기초금액 × 요율.
|
||
// 안전관리비: 대상액 T(직접공사비, 없으면 재료비 소계 + 비과세 + 직접노무비), 관급 = 「관급자재」 행.
|
||
// 기초금액 켬: Up (T × r + B) × 1.2 · Down (T + 관급/1.1) × r + B / 끔: Up T × r × 1.2 · Down (T + 관급/1.1) × r — 작은 쪽.
|
||
// 고르지 않은 식은 「비교 표시」를 켜면 바로 아래 계산 제외 행(CalcIndex 2)으로 남긴다.
|
||
const EstWongaTools = (() => {
|
||
const num = (value) => Number(value) || 0;
|
||
const plain = (text) => String(text ?? "").replace(/\s+/g, "");
|
||
const key = (row) => row?.Gubun || row?.NumStr || "";
|
||
const find = (rows, ...words) =>
|
||
rows.find((r) => num(r.CalcIndex) !== 2 && words.every((w) => plain(r.Name).includes(w)));
|
||
const decimal = (value) => String(Number(value.toFixed(10)));
|
||
|
||
function evaluate(rows, expression) {
|
||
const costs = new Map(rows.filter((r) => key(r)).map((r) => [String(key(r)), num(r.Cost)]));
|
||
return EstExpression.evaluate(expression, (name) => costs.get(String(name)), {
|
||
numericReferences: true,
|
||
});
|
||
}
|
||
|
||
function compareRow(rows, target, expression, show) {
|
||
const at = rows.indexOf(target),
|
||
existing = rows.findIndex(
|
||
(r, i) => i > at && num(r.CalcIndex) === 2 && plain(r.Name) === plain(target.Name),
|
||
);
|
||
if (!show) {
|
||
if (existing >= 0) rows.splice(existing, 1);
|
||
return;
|
||
}
|
||
const row =
|
||
existing >= 0 ? rows[existing] : (rows.splice(at + 1, 0, { ...target }), rows[at + 1]);
|
||
Object.assign(row, {
|
||
Gubun: "",
|
||
NumStr: "",
|
||
Bimog1: "",
|
||
Bimog2: "",
|
||
VSusic: expression,
|
||
CalcIndex: 2,
|
||
Checked: false,
|
||
LinkIndex: 0,
|
||
});
|
||
}
|
||
|
||
function gita(project, { gcRatio, baseRatio, youl, compare }) {
|
||
const rows = project.tables.Wonga || [];
|
||
if (![gcRatio, baseRatio].every((v) => Number.isFinite(v) && v >= 0 && v <= 1))
|
||
throw Error("1 이하의 수치값으로 입력 하세요");
|
||
if (!(Number.isFinite(youl) && youl >= 0 && youl <= 10))
|
||
throw Error("기타경비요율은 0~10% 입니다.");
|
||
const target = find(rows, "기타", "경비"),
|
||
a = find(rows, "직접공사비"),
|
||
b = find(rows, "직접노무비"),
|
||
c = find(rows, "간접노무비");
|
||
if (!target) throw Error("기타경비 비목이 없습니다.");
|
||
if (!a) throw Error("직접공사비 비목이 없어 기타경비를 계산할 수 없습니다.");
|
||
const first = [`(${key(a)} × ${decimal(gcRatio)})`, key(b), key(c)].filter(Boolean).join(" + "),
|
||
second = `${key(a)} × ${decimal(baseRatio)}`;
|
||
const v1 = evaluate(rows, first),
|
||
v2 = evaluate(rows, second),
|
||
rate = decimal(youl / 100);
|
||
const up = `(${first}) × ${rate}`,
|
||
down = `(${second}) × ${rate}`,
|
||
[chosen, other] = v2 >= v1 ? [up, down] : [down, up];
|
||
target.VSusic = chosen;
|
||
target.Youl = `${decimal(youl)}%`;
|
||
compareRow(rows, target, other, compare);
|
||
project.options.GitaGCRatio = gcRatio;
|
||
project.options.GitaBaseRatio = baseRatio;
|
||
return { chosen, other, values: [v1, v2] };
|
||
}
|
||
|
||
function anjunBase(rows) {
|
||
const direct = find(rows, "직접공사비");
|
||
if (direct) return key(direct);
|
||
const dm = find(rows, "직접재료비"),
|
||
im = find(rows, "간접재료비");
|
||
const subtotal = rows.find(
|
||
(r) =>
|
||
dm &&
|
||
String(r.VSusic ?? "").includes(key(dm)) &&
|
||
(!im || String(r.VSusic ?? "").includes(key(im))),
|
||
);
|
||
let t = subtotal ? key(subtotal) : [dm, im].filter(Boolean).map(key).join(" + ");
|
||
const free = find(rows, "비과세") || find(rows, "면세");
|
||
if (free) t += ` + ${key(free)}`;
|
||
const labor = find(rows, "직접노무비");
|
||
if (labor) t = `(${t} + ${key(labor)})`;
|
||
if (!t) throw Error("산업안전보건관리비 대상액 비목을 찾을 수 없습니다.");
|
||
return t;
|
||
}
|
||
|
||
function anjun(project, { youl, base, addBase, compare }) {
|
||
const rows = project.tables.Wonga || [];
|
||
if (!(Number.isFinite(youl) && youl >= 0 && youl <= 15)) throw Error("요율은 0~15% 입니다.");
|
||
const target = find(rows, "산업안전", "보건");
|
||
if (!target) throw Error("산업안전보건관리비 비목이 없습니다.");
|
||
const t = anjunBase(rows),
|
||
gov = find(rows, "관급자재"),
|
||
r = decimal(youl / 100),
|
||
b = addBase ? ` + ${Math.round(base)}` : "";
|
||
const up = addBase ? `(${t} × ${r}${b}) × 1.2` : `${t} × ${r} × 1.2`;
|
||
const down = gov && num(gov.Cost) ? `(${t} + ${key(gov)}/1.1) × ${r}${b}` : `${t} × ${r}${b}`;
|
||
const vu = evaluate(rows, up),
|
||
vd = evaluate(rows, down),
|
||
[chosen, other] = vd > vu ? [up, down] : [down, up];
|
||
target.VSusic = chosen;
|
||
target.Youl = `${decimal(youl)}%`;
|
||
compareRow(rows, target, other, compare);
|
||
project.options.AnjunBaseCost = base;
|
||
return { chosen, other, values: [vu, vd] };
|
||
}
|
||
|
||
function dialog(state, kind) {
|
||
const rows = state.project.tables.Wonga || [],
|
||
o = state.project.options,
|
||
rate = (row) => Number(String(row?.Youl ?? "").split("%")[0]) || 0;
|
||
const fields =
|
||
kind === "gita"
|
||
? [
|
||
{ name: "gcRatio", label: "재료비율(0~1)", type: "number", value: o.GitaGCRatio ?? "" },
|
||
{
|
||
name: "baseRatio",
|
||
label: "기초금액상한율(0~1)",
|
||
type: "number",
|
||
value: o.GitaBaseRatio ?? "",
|
||
},
|
||
{
|
||
name: "youl",
|
||
label: "기타경비요율(%)",
|
||
type: "number",
|
||
value: rate(find(rows, "기타", "경비")),
|
||
},
|
||
]
|
||
: [
|
||
{
|
||
name: "youl",
|
||
label: "안전관리비 요율(%)",
|
||
type: "number",
|
||
value: rate(find(rows, "산업안전", "보건")),
|
||
},
|
||
{
|
||
name: "addBase",
|
||
label: "기초금액 더하기",
|
||
value: "no",
|
||
choices: [
|
||
["no", "더하지 않음"],
|
||
["yes", "더함"],
|
||
],
|
||
},
|
||
{
|
||
name: "base",
|
||
label: "기초금액(원)",
|
||
type: "number",
|
||
value: o.AnjunBaseCost ?? 5349000,
|
||
},
|
||
];
|
||
fields.push({
|
||
name: "compare",
|
||
label: "비교 식 표시",
|
||
value: "yes",
|
||
choices: [
|
||
["yes", "아래 줄에 표시"],
|
||
["no", "표시 안 함"],
|
||
],
|
||
});
|
||
EstActions.form(
|
||
kind === "gita" ? "기타경비 계산" : "산업안전보건관리비 비교",
|
||
fields,
|
||
(values) => {
|
||
const input = {
|
||
...values,
|
||
compare: values.compare === "yes",
|
||
addBase: values.addBase === "yes",
|
||
};
|
||
const run = (project) => (kind === "gita" ? gita(project, input) : anjun(project, input));
|
||
run(structuredClone(state.project));
|
||
let out;
|
||
EstApp.change((project) => (out = run(project)));
|
||
state.notice = `작은 쪽 식을 골랐습니다: ${out.chosen}`;
|
||
},
|
||
);
|
||
}
|
||
|
||
// 선택항목 절하하기(popupPosition 모드 0, 확인 0xd094dc): 행 Diget·DigetIndex 기록, 도급액(55) 행 거부.
|
||
// 수식 행은 재계산이 자리맞춤, 입력 행은 원금액(TC, 없으면 현재 금액)을 자리맞춤해 Cost 로. 자리수는 다음 기본값으로 기억.
|
||
// accom = 기성 원가(도급액 행 거부 검사 없음).
|
||
function position(project, indices, method, digits, accom = false) {
|
||
const rows = project.tables.Wonga || [];
|
||
if (![0, 1, 2, 3].includes(method) || !Number.isInteger(digits) || digits < 0 || digits > 6)
|
||
throw Error("자리수는 0~6이고 처리방식을 골라야 합니다.");
|
||
if (indices.some((i) => !rows[i])) throw Error("절하할 줄을 고르세요.");
|
||
if (!accom && indices.some((i) => num(rows[i].LinkIndex) === 55))
|
||
throw Error("도급액 역산 자리수 지정한 라인은자리수를 별도로 지정할수 없습니다.");
|
||
for (const i of indices) {
|
||
const row = rows[i];
|
||
row.Diget = method ? digits : 0;
|
||
row.DigetIndex = method;
|
||
const formula = row.VSusic && ![1, 3].includes(num(row.CalcIndex)) && !row.BaseField;
|
||
if (formula) continue;
|
||
const base = num(row.TC) || num(row.Cost);
|
||
if (!base) continue;
|
||
row.TC = base;
|
||
row.Cost = method ? EstWonga.digetRound(row, base) : base;
|
||
}
|
||
project.options.WongaLastDiget = digits;
|
||
}
|
||
function positionDialog(state, indices) {
|
||
const rows = state.project.tables.Wonga,
|
||
unit = ["원", "십원", "백원", "천원", "만원", "십만원", "백만원"];
|
||
EstActions.form(
|
||
"선택항목 절하하기",
|
||
[
|
||
{
|
||
name: "method",
|
||
label: "처리방식",
|
||
value: num(rows[indices[0]].DigetIndex) || 2,
|
||
choices: [
|
||
[1, "사사오입"],
|
||
[2, "절하"],
|
||
[3, "절상"],
|
||
[0, "초기화"],
|
||
],
|
||
},
|
||
{
|
||
name: "digits",
|
||
label: "자리 (0 원 · 1 십원 … 6 백만원)",
|
||
type: "number",
|
||
value: num(state.project.options.WongaLastDiget),
|
||
},
|
||
],
|
||
(values) => {
|
||
const method = Number(values.method),
|
||
digits = Number(values.digits);
|
||
position(structuredClone(state.project), indices, method, digits);
|
||
EstApp.change((project) => position(project, indices, method, digits));
|
||
state.notice = method
|
||
? `${unit[digits]} 단위로 ${["", "사사오입", "절하", "절상"][method]}했습니다.`
|
||
: "자리수 지정을 풀었습니다.";
|
||
},
|
||
);
|
||
}
|
||
|
||
// 산출근거 약어로 표시(popupSNameShow 0xd0e494 · 약어 생성 0xc97790): 산출근거 식의 기호(영문 시작 또는 2자 이하,
|
||
// 범위 기호 뒤 제외)를 그 행의 약어(SName, 없으면 빈칸 뺀 이름)로, 숫자×100 이 그 행 요율과 같으면 요율 글자로, 「:」 는 「~」 로.
|
||
// 화면·인쇄에서 산출근거 대신 보이고 그동안 산출근거 칸은 잠근다(원본 VGungu). 저장 값은 바꾸지 않는다.
|
||
function abbreviate(rows, options = {}) {
|
||
const key = (r) =>
|
||
String(options.VerFormat === false ? (r.NumStr ?? r.Index ?? "") : (r.Gubun ?? "")).trim();
|
||
const keys = new Map();
|
||
rows.forEach((r) => {
|
||
const k = key(r);
|
||
if (k && !keys.has(k)) keys.set(k, r);
|
||
});
|
||
const label = (r) => String(r.SName || r.Name || "").replace(/\s/g, "");
|
||
return rows.map((row) => {
|
||
const s = String(row.VSusic ?? "").trim();
|
||
if (!s) return "";
|
||
const youl = String(row.Youl ?? "")
|
||
.replace("%", "")
|
||
.trim();
|
||
const tokens = s.match(/\d+(?:\.\d+)?|[A-Za-z가-힣_][A-Za-z0-9가-힣_]*|\S/g) || [];
|
||
return tokens
|
||
.map((t, i) => {
|
||
const prev = tokens[i - 1];
|
||
if (t === ":") return "~";
|
||
const numeric = /^\d+(?:\.\d+)?$/.test(t);
|
||
if (
|
||
(/^[A-Z]/.test(t) || (t.length <= 2 && /^[A-Za-z0-9가-힣]+$/.test(t))) &&
|
||
prev !== ":" &&
|
||
prev !== "~"
|
||
) {
|
||
const hit = keys.get(t);
|
||
if (hit) return label(hit);
|
||
}
|
||
if (numeric && youl && Number(youl) === Number((Number(t) * 100).toPrecision(12)))
|
||
return String(row.Youl).trim();
|
||
return t;
|
||
})
|
||
.join(" ");
|
||
});
|
||
}
|
||
|
||
function handle(name, state) {
|
||
if (name === "wonga-sname") {
|
||
EstApp.change(
|
||
(project) => (project.options.WongaSNameShow = !project.options.WongaSNameShow),
|
||
false,
|
||
);
|
||
return true;
|
||
}
|
||
if (name === "wonga-gita") dialog(state, "gita");
|
||
else if (name === "wonga-anjun") dialog(state, "anjun");
|
||
else return false;
|
||
return true;
|
||
}
|
||
|
||
return { gita, anjun, anjunBase, position, positionDialog, abbreviate, handle };
|
||
})();
|