feat(b09): 기성 2벌 — 계약잡비율 직접입력(사유·↺) · 단일율 곱 차이 표시 · 공급가액/총공사비 절사 9택 · 사정 칸

- 계약잡비율: 기본 역산값(도급액 ÷ 계약 직접공사비) · 계약서 값 직접입력이 이김(사유 필수)
- 줄별 절사 누적으로 단일율 곱과 벌어진 금회·누계 차이를 비고에 적음
- 공급가액 절사(총공사비에서 조정 + 10원~1억) · 총공사비 절사(이윤금액 직접입력 + 10원~1억)
  — 떨어지는 몫은 설계 원가계산서와 같은 식으로 이윤에서
- 사정: 뜻 미확인이라 칸만 받고 계산에 안 씀
- 시험 4건 보탬 · 전체 1723 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
2026-09-14 06:42:16 +09:00
co-authored by Claude Opus 5
parent ba2c36e803
commit efe55e9d34
4 changed files with 386 additions and 56 deletions
+145 -18
View File
@@ -29,14 +29,25 @@ interface Round {
quantities: Record<string, string>;
vat_mode: string;
vat_manual_krw: string;
supply_cut_krw: string;
total_cut_krw: string;
profit_manual_krw: string;
/** 사정 — 뜻 미확인이라 칸만(계산에 안 씀). */
assessed: Record<string, string>;
}
type Override = { rate_pct: string; reason: string };
type Overrides = Record<string, Override>;
type Choice = { key: string; label: string };
interface AmountRow {
key: string;
name: string;
contract_krw: string;
contract_ratio_pct?: string | null;
[column: string]: string | null | undefined;
default_ratio_pct?: string;
override?: Override | null;
[column: string]: string | null | undefined | Override;
}
interface ProgressRow {
@@ -64,8 +75,8 @@ interface ProgressDto {
round: number;
round_count: number;
notes: string[];
settings: { rounds: Round[] };
fields: { vat: { key: string; label: string }[] };
settings: { rounds: Round[]; rate_overrides: Overrides };
fields: { vat: Choice[]; supply_cut: Choice[]; total_cut: Choice[] };
limit_note: string;
}
@@ -113,12 +124,16 @@ function pct(value: string | boolean | null | undefined): string {
}
/** 프로젝트별 입력 캐시 — [저장] 전 값(지침 5장 · 자동저장 없음). 고른 회차도 함께. */
const drafts = new Map<string, { rounds: Round[]; round: number }>();
const drafts = new Map<string, { rounds: Round[]; overrides: Overrides; round: number }>();
function draftOf(projectId: string, data: ProgressDto) {
let draft = drafts.get(projectId);
if (!draft) {
draft = { rounds: structuredClone(data.settings.rounds), round: data.round };
draft = {
rounds: structuredClone(data.settings.rounds),
overrides: structuredClone(data.settings.rate_overrides),
round: data.round,
};
drafts.set(projectId, draft);
}
return draft;
@@ -136,24 +151,70 @@ async function fetchProgress(projectId: string, round: number | null): Promise<P
return body;
}
async function saveProgress(projectId: string, rounds: Round[]): Promise<void> {
async function saveProgress(
projectId: string,
rounds: Round[],
overrides: Overrides,
): Promise<void> {
const response = await fetch(endpoint(projectId), {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rounds }),
body: JSON.stringify({ rounds, rate_overrides: overrides }),
});
const body = (await response.json()) as { message?: string };
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
}
function choiceSelect(choices: Choice[], value: string, onChange: (v: string) => void) {
const node = el("select");
for (const choice of choices) {
const option = el("option", "", choice.label);
option.value = choice.key;
node.append(option);
}
node.value = value;
node.addEventListener("change", () => onChange(node.value));
return node;
}
/** 공급가액 절사 · 총공사비 절사(9택) · 이윤금액 직접입력 — 떨어지는 몫은 이윤에서. */
function drawCuts(data: ProgressDto, current: Round): HTMLElement[] {
const supply = el("label");
supply.append(
el("span", "", "공급가액 절사"),
choiceSelect(
data.fields.supply_cut,
current.supply_cut_krw,
(v) => (current.supply_cut_krw = v),
),
);
const total = el("label");
const profit = el("input");
profit.type = "number";
profit.min = "0";
profit.placeholder = "이윤금액(비우면 계약잡비율 값)";
profit.value = current.profit_manual_krw;
profit.hidden = current.total_cut_krw !== "";
profit.addEventListener("input", () => (current.profit_manual_krw = profit.value));
total.append(
el("span", "", "총공사비 절사"),
choiceSelect(data.fields.total_cut, current.total_cut_krw, (v) => {
current.total_cut_krw = v;
profit.hidden = v !== "";
}),
profit,
);
return [supply, total];
}
function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number) => void): void {
const projectId = ctx.projectId as string;
const draft = draftOf(projectId, data);
const box = el("div", "b09pg__panel");
const save = async (round: number): Promise<void> => {
try {
await saveProgress(projectId, draft.rounds);
await saveProgress(projectId, draft.rounds, draft.overrides);
drafts.delete(projectId);
showToast("기성 회차 저장 — 계약 내역은 그대로", "success");
reload(round);
@@ -183,6 +244,10 @@ function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number
quantities: {},
vat_mode: last?.vat_mode ?? "supply",
vat_manual_krw: "",
supply_cut_krw: last?.supply_cut_krw ?? "",
total_cut_krw: last?.total_cut_krw ?? "",
profit_manual_krw: "",
assessed: {},
});
void save(draft.rounds.length);
},
@@ -224,7 +289,8 @@ function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number
});
manual.addEventListener("input", () => (current.vat_manual_krw = manual.value));
vat.append(mode, manual);
box.append(vat, createButton({ label: "저장", onClick: () => save(data.round) }));
box.append(vat, ...drawCuts(data, current));
box.append(createButton({ label: "저장", onClick: () => save(data.round) }));
} else {
box.append(el("div", "b09pg__meta", "회차 없음 — [회차 추가]로 1회부터"));
}
@@ -232,7 +298,7 @@ function drawPanel(ctx: B09TabContext, data: ProgressDto, reload: (round: number
}
/** 기성 제잡비 계산서 — 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액). */
function drawJab(data: ProgressDto): HTMLElement {
function drawJab(data: ProgressDto, overrides: Overrides): HTMLElement {
const box = el("div");
box.append(
el(
@@ -240,22 +306,67 @@ function drawJab(data: ProgressDto): HTMLElement {
"",
`기성 제잡비 계산서 — 금회직접공사비 × 계약잡비율 · 계약 제잡비율 ${pct(data.contract_overhead_ratio_pct)}`,
),
el(
"div",
"b09pg__meta",
"계약잡비율 기본 = 역산(도급액 ÷ 계약 직접공사비) · 계약서 값이 다르면 직접입력 + 사유 → [저장] · ↺ 로 역산값",
),
);
const table = el("table", "b09pg__table");
const head = el("tr");
for (const label of ["명칭", "도급액", "계약잡비율", ...COLUMNS.flatMap(([, a, r]) => [a, r])]) {
for (const label of [
"명칭",
"도급액",
"계약잡비율",
"직접입력(%)",
"사유",
"",
...COLUMNS.flatMap(([, a, r]) => [a, r]),
]) {
head.append(el("th", "", label));
}
table.append(head);
const line = (row: AmountRow, total: boolean): void => {
const tr = el("tr", total ? "is-total" : "");
tr.append(
el("td", "", row.name),
el("td", "num", won(row.contract_krw)),
el("td", "num", pct(row.contract_ratio_pct)),
);
const ratio = el("td", "num", pct(row.contract_ratio_pct));
if (row.override) ratio.title = `역산값 ${row.default_ratio_pct}% — 직접입력이 이김`;
tr.append(el("td", "", row.name), el("td", "num", won(row.contract_krw)), ratio);
const cells = [el("td"), el("td"), el("td")];
if (!total) {
const entry = (): Override => (overrides[row.key] ??= { rate_pct: "", reason: "" });
const rate = el("input");
rate.type = "number";
rate.min = "0";
rate.step = "any";
rate.placeholder = row.default_ratio_pct ?? "";
rate.value = overrides[row.key]?.rate_pct ?? "";
rate.addEventListener("input", () => (entry().rate_pct = rate.value));
const reason = el("input");
reason.placeholder = "사유(계약서 등)";
reason.style.width = "12em";
reason.style.textAlign = "left";
reason.value = overrides[row.key]?.reason ?? "";
reason.addEventListener("input", () => (entry().reason = reason.value));
const revert = el("button", "", "↺");
revert.type = "button";
revert.title = "역산값으로 되돌림 — [저장]하면 반영";
revert.disabled = !overrides[row.key];
revert.addEventListener("click", () => {
delete overrides[row.key];
rate.value = "";
reason.value = "";
});
cells[0].append(rate);
cells[1].append(reason);
cells[2].append(revert);
}
tr.append(...cells);
for (const [col] of COLUMNS) {
tr.append(el("td", "num", won(row[`${col}_krw`])), el("td", "num", pct(row[`${col}_pct`])));
const text = (key: string): string | null => {
const value = row[key];
return typeof value === "string" ? value : null;
};
tr.append(el("td", "num", won(text(`${col}_krw`))), el("td", "num", pct(text(`${col}_pct`))));
}
table.append(tr);
};
@@ -281,6 +392,7 @@ function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement {
"전회금액",
"금회수량",
"금회금액",
"사정(칸만)",
"누계수량",
"누계금액",
"기성(%)",
@@ -317,9 +429,24 @@ function drawBill(data: ProgressDto, rounds: Round[]): HTMLElement {
});
cell.append(qty);
}
// 사정 — 뜻 미확인(「기성내역서(사정)」 열 이름뿐) · 계산에 안 씀.
const assessed = el("td");
if (leaf && current) {
const amount = el("input");
amount.type = "number";
amount.min = "0";
amount.title = "사정 — 뜻 확인 대기 · 금액에 안 섞임";
amount.value = current.assessed[row.item_no] ?? "";
amount.addEventListener("input", () => {
if (amount.value === "") delete current.assessed[row.item_no];
else current.assessed[row.item_no] = amount.value;
});
assessed.append(amount);
}
tr.append(
cell,
el("td", "num", won(row.progress_current_amount_krw)),
assessed,
el("td", "num", leaf ? String(row.progress_cumulative_quantity) : ""),
el("td", "num", won(row.progress_cumulative_amount_krw)),
el("td", "num", pct(row.progress_pct)),
@@ -339,7 +466,7 @@ function drawBody(ctx: B09TabContext, data: ProgressDto): void {
for (const note of data.notes) wrap.append(el("div", "b09pg__warn", `⚠ ${note}`));
wrap.append(el("div", "b09pg__meta", "금회 기성수량을 넣고 [저장]하면 반영"));
const scroll = el("div", "b09pg__scroll");
scroll.append(drawJab(data), drawBill(data, draft.rounds));
scroll.append(drawJab(data, draft.overrides), drawBill(data, draft.rounds));
wrap.append(scroll);
ctx.body.append(wrap);
}