사용자 확정 2차 ③ — 기술문서 DB 를 먼저 뒤지고, 계수 원문이 없으면 실무 기준 + 노티스. 근거 (원문 확인분) - 대한건설협회 임금적용요령 4-나 (재경원 회계45101-45, 1995.1.13): 공표 노임은 **기본급여액**이며 제수당·상여금·퇴직급여충당금은 예정가격작성기준에 따라 별도 계상 - 기재부 「(계약예규) 정부 입찰·계약 집행기준」 제76조의3(노무비의 계상): 노임 + 제수당 + 상여금(연 400% 한도) + 퇴직급여충당금 - 품셈 8-1-2 5.6호가 운전사 노임을 그 예규로 넘김 미확인 (노티스로 드러냄) - 예규 원문 자체는 저장소에 없음 — 위 둘은 인용 문서 - 16/12 는 상여 연 400% 와 산술이 일치하나 25/20 은 원문 근거 못 찾음 - 실물 셋이 같은 계수 사용: 실무 두 공사지·임도교본 예제·다산 ESTX 기준계수 0.20833 구현 - `OPERATOR_ALLOWANCE_FACTOR` = 16/12 × 25/20, 근거·한계 주석을 이 한 곳에 모음 - 나눗수 8시간은 그대로 두고 **곱셈으로만** 적용 — 나눗수를 줄이면 작업효율을 시간당 사용료에 넣은 것이 되어 ㉣ 위반. 검사(`check_operator_hours_basis`)에도 계수를 넘겨 곱셈 자리를 드러냄 - 일위대가 조종원 줄·A10 인건비·중기 탭 노티스를 같은 문구로 맞춤 검증 (79da822d 기준) - 굴착기 0.7 86,096.4 → 109,704.5 (+27.4%) · 불도저 19t 124,842.3 → 148,450.4 (+18.9%) - 조종원 수량 0.20833 = 다산 ESTX 기준계수와 일치 - 실무 시트 재현: 267,360 ÷ 8 × 계수 = 55,700 (시트 값과 일원까지 일치) - 본체 11,356,910 → 12,141,903 (+784,993, +6.9%) - pytest 267 통과 · tsc 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
/* =============================================================================
|
||
* B09_Estimation_UI_BaseData.ts
|
||
* 기초자료 탭 · 중기 탭 — 목록표 넷을 그린다 (사용자 확정 12번 「내야 할 표 16개 전체」).
|
||
*
|
||
* 기초자료 탭 : 노무비목록표 · 재료비목록표 · 경비목록표
|
||
* 중기 탭 : 중기목록표 (합계 + 노무·재료·경비 3분할)
|
||
*
|
||
* 서식은 지어내지 않았다 — 실무 내역서(영월 기번6 · 봉화 기번41)의 같은 이름 시트를
|
||
* 그대로 옮겼다. 칸 이름·차례가 그 시트와 같다.
|
||
*
|
||
* 화면 조립부(`B09_Estimation_UI_Page.ts`)가 이미 700줄을 크게 넘어 여기로 뺐다.
|
||
* 표를 그리는 일만 하고 **상태를 들지 않는다** — 부르는 쪽이 자료를 넘긴다.
|
||
* ========================================================================== */
|
||
|
||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||
import { API_BASE_URL } from "@config/config_frontend";
|
||
|
||
function L(key: keyof typeof ui_locales): string {
|
||
return ui_locales[key][currentLanguageIndex];
|
||
}
|
||
|
||
/** 목록표 한 줄 — 실무 시트 칸 그대로. */
|
||
export interface BaseDataRow {
|
||
code: string;
|
||
name: string;
|
||
spec: string;
|
||
unit: string;
|
||
unit_price_krw: string | null;
|
||
note: string;
|
||
}
|
||
|
||
/** 중기목록표 한 줄 — 합계와 3분할을 함께 보인다. */
|
||
export interface MachineRow {
|
||
code: string;
|
||
name: string;
|
||
spec: string;
|
||
unit: string;
|
||
total_krw: string | null;
|
||
labor_krw: string | null;
|
||
material_krw: string | null;
|
||
expense_krw: string | null;
|
||
note: string;
|
||
}
|
||
|
||
export interface BaseDataDto {
|
||
status: string;
|
||
labor: BaseDataRow[];
|
||
material: BaseDataRow[];
|
||
expense: BaseDataRow[];
|
||
machine: MachineRow[];
|
||
}
|
||
|
||
export async function fetchBaseData(projectId: string): Promise<BaseDataDto> {
|
||
const response = await fetch(
|
||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/base-data`,
|
||
{ credentials: "include" },
|
||
);
|
||
if (!response.ok) throw new Error(`base-data ${response.status}`);
|
||
return (await response.json()) as BaseDataDto;
|
||
}
|
||
|
||
function money(value: string | null): string {
|
||
if (value === null || value === "") return "";
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed.toLocaleString("ko-KR") : value;
|
||
}
|
||
|
||
function head(text: string): HTMLElement {
|
||
const el = document.createElement("div");
|
||
el.className = "b09-hint";
|
||
el.style.fontWeight = "600";
|
||
el.textContent = text;
|
||
return el;
|
||
}
|
||
|
||
function note(text: string): HTMLElement {
|
||
const el = document.createElement("div");
|
||
el.className = "b09-hint";
|
||
el.textContent = text;
|
||
return el;
|
||
}
|
||
|
||
function table(headers: string[], rows: string[][], leftCols: number[]): HTMLElement {
|
||
const el = document.createElement("table");
|
||
el.className = "b09-sheet";
|
||
const thead = document.createElement("thead");
|
||
const headRow = document.createElement("tr");
|
||
headers.forEach((text, index) => {
|
||
const th = document.createElement("th");
|
||
th.textContent = text;
|
||
if (leftCols.includes(index)) th.className = "b09-left";
|
||
headRow.append(th);
|
||
});
|
||
thead.append(headRow);
|
||
const tbody = document.createElement("tbody");
|
||
for (const cells of rows) {
|
||
const tr = document.createElement("tr");
|
||
cells.forEach((text, index) => {
|
||
const td = document.createElement("td");
|
||
td.textContent = text;
|
||
if (leftCols.includes(index)) td.className = "b09-left";
|
||
tr.append(td);
|
||
});
|
||
tbody.append(tr);
|
||
}
|
||
el.append(thead, tbody);
|
||
return el;
|
||
}
|
||
|
||
/** 목록표 한 장 — 코드·명칭·규격·단위·단가·비고 (실무 시트와 같은 칸). */
|
||
function catalogTable(rows: BaseDataRow[]): HTMLElement {
|
||
return table(
|
||
["코드번호", "명 칭", "규 격", "단위", "단 가", "비 고"],
|
||
rows.map((row) => [
|
||
row.code,
|
||
row.name,
|
||
row.spec,
|
||
row.unit,
|
||
money(row.unit_price_krw),
|
||
row.note,
|
||
]),
|
||
[0, 1, 2, 5],
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 기초자료 탭 — 목록표 셋.
|
||
*
|
||
* ⚠ 표가 비거나 한 줄뿐일 때 **그냥 두지 않는다** — 「다 채운 것」으로 읽히기 때문이다.
|
||
* 재료비목록표가 지금 그 자리다(사급 자재 카탈로그가 아직 안 섰다).
|
||
*/
|
||
export function drawBaseDataTab(body: HTMLElement, data: BaseDataDto): void {
|
||
const groups: Array<[string, BaseDataRow[], string]> = [
|
||
["노무비목록표", data.labor, ""],
|
||
[
|
||
"재료비목록표",
|
||
data.material,
|
||
data.material.length <= 1
|
||
? "⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 자재값 출처(업체 견적·물가지)를 붙이면 채워집니다."
|
||
: "",
|
||
],
|
||
["경비목록표", data.expense, "기계 취득가격입니다(천원) — 시간당 사용료는 「중기」 탭입니다."],
|
||
];
|
||
for (const [title, rows, hint] of groups) {
|
||
body.append(head(`${title} (${rows.length})`));
|
||
if (hint) body.append(note(hint));
|
||
if (rows.length === 0) {
|
||
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
|
||
continue;
|
||
}
|
||
body.append(catalogTable(rows));
|
||
}
|
||
}
|
||
|
||
/** 중기 탭 — 중기목록표. 합계와 3분할을 함께 보인다(실무 시트와 같은 칸). */
|
||
export function drawMachineTab(body: HTMLElement, data: BaseDataDto): void {
|
||
body.append(head(`중기목록표 (${data.machine.length})`));
|
||
if (data.machine.length === 0) {
|
||
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
|
||
return;
|
||
}
|
||
body.append(
|
||
table(
|
||
["코드번호", "명 칭", "규 격", "단위", "합 계", "노 무 비", "재 료 비", "경 비", "비 고"],
|
||
data.machine.map((row) => [
|
||
row.code,
|
||
row.name,
|
||
row.spec,
|
||
row.unit,
|
||
money(row.total_krw),
|
||
money(row.labor_krw),
|
||
money(row.material_krw),
|
||
money(row.expense_krw),
|
||
row.note,
|
||
]),
|
||
[0, 1, 2, 8],
|
||
),
|
||
);
|
||
// ⚠ 계산 과정을 감추지 않는다(PLAN 8-13). 조종원 환산이 실무와 다른 것을 여기서 밝힌다.
|
||
body.append(
|
||
note(
|
||
"조종원 노임은 「노임 ÷ 8시간 × 16/12 × 25/20」(약 1.667배)으로 셉니다. " +
|
||
"공표 노임은 기본급여액뿐이라 제수당·상여금·퇴직급여충당금을 따로 계상해야 " +
|
||
"합니다(건협 임금적용요령 4-나 · 기재부 정부 입찰·계약 집행기준 제76조의3). " +
|
||
"⚠ 계수 자체의 예규 원문은 아직 못 봐 실무 관행을 따랐습니다 — 실무 두 공사지· " +
|
||
"임도교본 예제·상용 적산 프로그램이 모두 같은 계수를 씁니다.",
|
||
),
|
||
);
|
||
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다."));
|
||
}
|
||
|
||
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
|
||
export function drawBaseDataError(body: HTMLElement): void {
|
||
body.append(note(L("B09_Estimation_Tab_Pending")));
|
||
}
|
||
|
||
/* =============================================================================
|
||
* 자재단가대비표(A9) · 환율및기초자료(A10) — 사용자 확정 ③·⑮ 와 한 벌.
|
||
*
|
||
* 표를 냈는데 화면에 없으면 **낸 것이 아니다.** 그래서 기초자료 탭 아래에 붙인다.
|
||
* 서식은 실무 「자재단가대비표」·「환율및기초자료」 시트를 그대로 옮겼다 —
|
||
* 원천마다 **단가·페이지** 두 칸이 서고, 채택한 원천에 표시가 붙는다.
|
||
* ========================================================================== */
|
||
|
||
/** 원천 한 칸 — 값이 없으면 **빈칸**이다. 0 을 넣으면 「0원짜리 견적」으로 읽힌다. */
|
||
export interface PriceSlot {
|
||
name: string;
|
||
price_krw: string | null;
|
||
/** 「페이지」 자리 — 물가지는 쪽수, 견적은 업체명·날짜(확정 ③). */
|
||
source_note: string;
|
||
adopted: boolean;
|
||
}
|
||
|
||
export interface MaterialComparisonRow {
|
||
code: string;
|
||
name: string;
|
||
spec: string;
|
||
unit: string;
|
||
slots: PriceSlot[];
|
||
adopted_slot: number;
|
||
adopted_price_krw: string | null;
|
||
note: string;
|
||
}
|
||
|
||
export interface FuelScope {
|
||
key: string;
|
||
label: string;
|
||
available: boolean;
|
||
why?: string;
|
||
}
|
||
|
||
export interface PriceSourcesDto {
|
||
status: string;
|
||
material_comparison: {
|
||
slot_names: string[];
|
||
rows: MaterialComparisonRow[];
|
||
notes: string[];
|
||
};
|
||
base_reference: {
|
||
exchange: { rows: unknown[]; note: string };
|
||
labor: {
|
||
rows: Array<{
|
||
code: string;
|
||
name: string;
|
||
day_wage_krw: string | null;
|
||
hourly_krw: string | null;
|
||
formula: string;
|
||
}>;
|
||
note: string;
|
||
};
|
||
fuel: {
|
||
diesel_krw_per_l: string | null;
|
||
scope: string;
|
||
effective_date: string;
|
||
dataset_id: string;
|
||
scopes: FuelScope[];
|
||
note: string;
|
||
};
|
||
};
|
||
}
|
||
|
||
export async function fetchPriceSources(projectId: string): Promise<PriceSourcesDto> {
|
||
const response = await fetch(
|
||
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/price-sources`,
|
||
{ credentials: "include" },
|
||
);
|
||
if (!response.ok) throw new Error(`price-sources ${response.status}`);
|
||
return (await response.json()) as PriceSourcesDto;
|
||
}
|
||
|
||
/**
|
||
* 자재단가대비표 — 원천마다 **단가·페이지** 두 칸이라 머리글이 두 줄이다.
|
||
* 채택한 원천 칸에 표시를 넣어 「어느 것을 썼나」가 한눈에 보이게 한다.
|
||
*/
|
||
function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HTMLElement {
|
||
const el = document.createElement("table");
|
||
el.className = "b09-sheet";
|
||
|
||
const thead = document.createElement("thead");
|
||
const top = document.createElement("tr");
|
||
const bottom = document.createElement("tr");
|
||
// ⚠ 슬롯 6 이 곧 「적용 단가」다(`JUKNM=6`). 그 자리에 「적 용」 칸을 또 세우면
|
||
// 같은 값이 두 번 선다 — 실무 시트도 원천 다섯 + 적용 하나로 끝난다.
|
||
const appliedIsLastSlot =
|
||
rows.length > 0 && rows.every((row) => row.adopted_slot === slotNames.length);
|
||
const trailing = appliedIsLastSlot ? [] : ["적 용"];
|
||
const fixed = ["코드번호", "명 칭", "규 격", "단위"];
|
||
fixed.forEach((text, index) => {
|
||
const th = document.createElement("th");
|
||
th.textContent = text;
|
||
th.rowSpan = 2;
|
||
if (index <= 2) th.className = "b09-left";
|
||
top.append(th);
|
||
});
|
||
for (const name of [...slotNames, ...trailing]) {
|
||
const th = document.createElement("th");
|
||
th.textContent = name;
|
||
th.colSpan = 2;
|
||
top.append(th);
|
||
for (const sub of ["단 가", "페이지"]) {
|
||
const cell = document.createElement("th");
|
||
cell.textContent = sub;
|
||
bottom.append(cell);
|
||
}
|
||
}
|
||
const noteHead = document.createElement("th");
|
||
noteHead.textContent = "비 고";
|
||
noteHead.rowSpan = 2;
|
||
noteHead.className = "b09-left";
|
||
top.append(noteHead);
|
||
thead.append(top, bottom);
|
||
|
||
const tbody = document.createElement("tbody");
|
||
for (const row of rows) {
|
||
const tr = document.createElement("tr");
|
||
const put = (text: string, left = false): void => {
|
||
const td = document.createElement("td");
|
||
td.textContent = text;
|
||
if (left) td.className = "b09-left";
|
||
tr.append(td);
|
||
};
|
||
put(row.code, true);
|
||
put(row.name, true);
|
||
put(row.spec, true);
|
||
put(row.unit);
|
||
for (const slot of row.slots) {
|
||
const td = document.createElement("td");
|
||
td.textContent = money(slot.price_krw);
|
||
// 채택한 원천을 굵게 — 「어느 값을 썼나」를 표가 스스로 밝힌다.
|
||
if (slot.adopted) td.style.fontWeight = "700";
|
||
tr.append(td);
|
||
const page = document.createElement("td");
|
||
page.textContent = slot.source_note;
|
||
page.className = "b09-left";
|
||
tr.append(page);
|
||
}
|
||
if (!appliedIsLastSlot) {
|
||
put(money(row.adopted_price_krw));
|
||
put(row.adopted_slot ? (row.slots[row.adopted_slot - 1]?.name ?? "") : "", true);
|
||
}
|
||
put(row.note, true);
|
||
tbody.append(tr);
|
||
}
|
||
el.append(thead, tbody);
|
||
return el;
|
||
}
|
||
|
||
/** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */
|
||
function baseReferenceSections(body: HTMLElement, data: PriceSourcesDto["base_reference"]): void {
|
||
body.append(head("환율및기초자료 — ① 환율"));
|
||
body.append(note(data.exchange.note));
|
||
|
||
body.append(head(`환율및기초자료 — ② 인건비 (${data.labor.rows.length})`));
|
||
if (data.labor.rows.length === 0) {
|
||
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
|
||
} else {
|
||
body.append(
|
||
table(
|
||
["코드번호", "직 종", "일 당", "시간당", "산 식"],
|
||
data.labor.rows.map((row) => [
|
||
row.code,
|
||
row.name,
|
||
money(row.day_wage_krw),
|
||
money(row.hourly_krw),
|
||
row.formula,
|
||
]),
|
||
[0, 1, 4],
|
||
),
|
||
);
|
||
}
|
||
// 시간당이 소수로 남는 까닭을 밝힌다 — 안 밝히면 「덜 다듬은 값」으로 읽힌다.
|
||
body.append(
|
||
note(
|
||
"시간당은 나눈 값을 그대로 둡니다 — 여기서 원 단위로 자르면 기계 시간당 사용료가 " +
|
||
"조금씩 어긋납니다. 자르는 자리는 일위대가·내역서 쪽입니다.",
|
||
),
|
||
);
|
||
body.append(note(data.labor.note));
|
||
|
||
body.append(head("환율및기초자료 — ③ 단가 및 재료비"));
|
||
const fuel = data.fuel;
|
||
body.append(
|
||
table(
|
||
["항 목", "단 가", "적용 범위", "기준일", "자료"],
|
||
[
|
||
[
|
||
"경유",
|
||
money(fuel.diesel_krw_per_l),
|
||
fuel.scopes.find((scope) => scope.key === fuel.scope)?.label || fuel.scope,
|
||
fuel.effective_date,
|
||
fuel.dataset_id,
|
||
],
|
||
],
|
||
[0, 2, 3, 4],
|
||
),
|
||
);
|
||
|
||
// ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고
|
||
// 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다.
|
||
const picker = document.createElement("div");
|
||
picker.className = "b09-hint";
|
||
picker.style.display = "flex";
|
||
picker.style.alignItems = "center";
|
||
picker.style.gap = "8px";
|
||
const label = document.createElement("span");
|
||
label.textContent = "유가 적용 범위";
|
||
const select = document.createElement("select");
|
||
for (const scope of fuel.scopes) {
|
||
const option = document.createElement("option");
|
||
option.value = scope.key;
|
||
option.textContent = scope.available ? scope.label : `${scope.label} (자료 없음)`;
|
||
option.disabled = !scope.available;
|
||
option.selected = scope.key === fuel.scope;
|
||
select.append(option);
|
||
}
|
||
picker.append(label, select);
|
||
body.append(picker);
|
||
for (const scope of fuel.scopes) {
|
||
if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`));
|
||
}
|
||
body.append(note(fuel.note));
|
||
}
|
||
|
||
/** 기초자료 탭 아래쪽 — A9·A10 두 장. */
|
||
export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDto): void {
|
||
const comparison = data.material_comparison;
|
||
body.append(head(`자재단가대비표 (${comparison.rows.length})`));
|
||
if (comparison.rows.length <= 1) {
|
||
body.append(
|
||
note(
|
||
"⚠ 사급 자재 카탈로그가 아직 서지 않아 줄이 거의 없습니다 — 재료비목록표와 같은 원인입니다.",
|
||
),
|
||
);
|
||
}
|
||
if (comparison.rows.length === 0) {
|
||
body.append(note("아직 선 줄이 없습니다 — 값을 0 으로 채우지 않습니다."));
|
||
} else {
|
||
body.append(comparisonTable(comparison.slot_names, comparison.rows));
|
||
}
|
||
for (const text of comparison.notes) body.append(note(text));
|
||
|
||
baseReferenceSections(body, data.base_reference);
|
||
}
|
||
|
||
/** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */
|
||
export function drawPriceSourcesPending(body: HTMLElement): void {
|
||
body.append(note("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
|
||
}
|