Files
Aislo/B09_Estimation/B09_Estimation_UI_BaseData.ts
T
eomsangdonandClaude Opus 5 5780eb9359 feat(B09): 흙깎기가 처음으로 금액이 섬 — 범위 계수·장비 규격을 고르는 칸 (확정 ①)
사용자 확정 ① — 작업효율 0.50(두 끝의 평균). 딸림 지시 「값을 코드에 박고 끝내지 말 것 ·
화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」를 그대로 구현.

막혔던 자리 둘
- 품셈 9-3-2 가 작업효율을 「0.55∼0.45」 범위로 줌 → 확정값이 아니라 계수가 안 섬
- 그 표에 장비가 없음 → [주]① 「무한궤도 굴착기(0.7㎥)」가 마스터에 안 실려 기종을 못 고름
⇒ 두 자리를 채워 흙깎기 단가가 처음으로 섬: 2,515.1원/㎥ × 2,355.84㎥ ≒ 592만원

구현
- `B09_Estimation_FactorChoices` 신설 — 범위 칸을 품셈에서 훑어 모으고(코드 안 박음),
  고를 수 있는 것은 원문 두 끝과 그 평균 셋뿐. 기본은 평균
- 장비 규격도 같은 결로: 흙깎기는 [주] 에만 있는 값을 채우는 칸, 층따기는 원문 0.7㎥ 를
  기본으로 두고 실무(영월 0.2㎥)로 바꿀 수 있는 칸
- 고른 값은 프로젝트 설정 `estimation` 구획에 저장 — 프로젝트마다 갈림
- `cached_build` 를 고른 값별로 캐시 (전역 한 벌이면 한 프로젝트가 남의 금액을 흔듦)
- `GET/PUT /{project_id}/estimation/factors` · 기초자료 탭 맨 위에 칸과 근거 표시

⚠ 짓다 잡은 것 — 「0.45-0.05」를 범위로 잘못 읽고 있었음. 그건 뺄셈(=0.40)이라
품셈이 이미 정한 값인데 「고를 것」으로 둔갑했음. 물결(∼) 일 때만 범위로 봄

검증: pytest 279 통과(신규 7) · tsc 통과 · 평균 2,515.1 / 하한 2,794.6 으로
고른 값이 단가에 실제로 닿는 것 확인
⚠ 화면 확인은 못 함 — 내 창 브라우저 세션이 만료됐고 자격 파일이 이 폴더에 없음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 06:27:24 +09:00

604 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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("자재단가대비표·환율및기초자료를 불러오는 중입니다…"));
}
/* =============================================================================
* 산출 조건 — 품셈이 범위로 준 계수·장비 규격 (사용자 확정 ① 딸림 지시, 2026-09-09)
*
* 「값을 코드에 박고 끝내지 말 것 · 화면에 칸으로 세우고 근거를 보이고 바꿀 수 있게」
* 라는 지시 그대로다. 고를 수 있는 것은 **원문에 적힌 값뿐**이고, 왜 그 값인지를
* 칸 밑에 그대로 적는다.
* ========================================================================== */
export interface FactorOption {
key: string;
value?: string;
label: string;
note?: string;
}
export interface RangeFactorRow {
key: string;
work_item_code: string;
work_item_name: string;
factor: string;
raw_cell: string;
chosen: string;
value: string;
is_default: boolean;
options: FactorOption[];
basis: string[];
}
export interface MachineChoiceRow {
work_item_code: string;
work_item_name: string;
chosen: string;
default: string;
is_default: boolean;
source: string;
options: FactorOption[];
basis: string[];
}
export interface FactorChoicesDto {
status: string;
ranges: RangeFactorRow[];
machines: MachineChoiceRow[];
notes: string[];
}
export async function fetchFactorChoices(projectId: string): Promise<FactorChoicesDto> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`factors ${response.status}`);
return (await response.json()) as FactorChoicesDto;
}
export async function saveFactorChoices(
projectId: string,
body: { range_factor_choices?: Record<string, string>; machine_choices?: Record<string, string> },
): Promise<void> {
const response = await fetch(
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/factors`,
{
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
if (!response.ok) throw new Error(`factors save ${response.status}`);
}
function picker(
label: string,
options: FactorOption[],
chosen: string,
onPick: (key: string) => void,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b09-hint";
wrap.style.display = "flex";
wrap.style.alignItems = "center";
wrap.style.gap = "8px";
wrap.style.flexWrap = "wrap";
const name = document.createElement("span");
name.style.fontWeight = "600";
name.textContent = label;
const select = document.createElement("select");
for (const option of options) {
const item = document.createElement("option");
item.value = option.key;
item.textContent = option.label;
item.selected = option.key === chosen;
select.append(item);
}
select.addEventListener("change", () => onPick(select.value));
wrap.append(name, select);
return wrap;
}
/**
* 산출 조건 구역 — 기초자료 탭 맨 위에 선다.
*
* ⚠ **기본값으로 돌고 있음을 숨기지 않는다** — 조용히 기본으로 돌면 사용자는 그것이
* 잠정인 줄도 모른다(타설 방식에서 이미 겪은 자리).
*/
export function drawFactorChoices(
body: HTMLElement,
data: FactorChoicesDto,
projectId: string,
reload: () => void,
): void {
body.append(head("산출 조건 — 품셈이 한 값으로 안 준 자리"));
for (const row of data.ranges) {
const title = `${row.work_item_name} 작업효율(${row.factor})`;
body.append(
picker(title, row.options, row.chosen, (key) => {
void saveFactorChoices(projectId, { range_factor_choices: { [row.key]: key } }).then(
reload,
);
}),
);
body.append(
note(
`품셈 원문은 「${row.raw_cell}」 — 지금 쓰는 값 ${row.value}` +
(row.is_default ? " (기본값으로 돌고 있습니다)" : " (사용자가 고른 값입니다)"),
),
);
for (const line of row.basis) body.append(note(line));
}
for (const row of data.machines) {
body.append(
picker(`${row.work_item_name} 장비 규격`, row.options, row.chosen, (key) => {
void saveFactorChoices(projectId, {
machine_choices: { [row.work_item_code]: key },
}).then(reload);
}),
);
body.append(
note(
row.source === "note"
? "⚠ 이 장비는 품셈 표가 아니라 [주] 에 적혀 있어 공종 마스터가 아직 못 싣는 값입니다 — 이 칸이 그 자리를 대신합니다."
: "품셈 표가 정한 장비입니다." +
(row.is_default ? "" : " ⚠ 지금은 사용자가 바꾼 값으로 돌고 있습니다."),
),
);
for (const line of row.basis) body.append(note(line));
}
for (const line of data.notes) body.append(note(line));
}