Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_1
This commit is contained in:
@@ -770,6 +770,15 @@
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "stone_supply",
|
||||
"label": "조달",
|
||||
"input": "select",
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "side",
|
||||
"label": "설치 측",
|
||||
@@ -844,6 +853,15 @@
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "stone_supply",
|
||||
"label": "조달",
|
||||
"input": "select",
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "side",
|
||||
"label": "설치 측",
|
||||
@@ -983,6 +1001,15 @@
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "stone_supply",
|
||||
"label": "조달",
|
||||
"input": "select",
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "side",
|
||||
"label": "설치 측",
|
||||
@@ -1079,6 +1106,15 @@
|
||||
"required": true,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "stone_supply",
|
||||
"label": "조달",
|
||||
"input": "select",
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "length_m",
|
||||
"label": "길이",
|
||||
@@ -1115,6 +1151,15 @@
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "stone_supply",
|
||||
"label": "조달",
|
||||
"input": "select",
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "area_m2",
|
||||
"label": "면적",
|
||||
@@ -1214,6 +1259,15 @@
|
||||
"required": false,
|
||||
"phase": "b05"
|
||||
},
|
||||
{
|
||||
"key": "stone_supply",
|
||||
"label": "조달",
|
||||
"input": "select",
|
||||
"choices": ["채집", "구입"],
|
||||
"default": "채집",
|
||||
"required": false,
|
||||
"phase": "detail"
|
||||
},
|
||||
{
|
||||
"key": "height_m",
|
||||
"label": "높이",
|
||||
|
||||
@@ -406,9 +406,16 @@ def _structure_rows(
|
||||
#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.)
|
||||
PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"})
|
||||
|
||||
#: 버림 타설 줄의 갈래 이름 — **실무 내역 표기 그대로**(「레미콘타설(장비) 무근,버림」).
|
||||
#: ⚠ 버림은 늘 무근이라 구조물 종류(무근/철근)를 따라가지 않는다.
|
||||
BLINDING_PLACING_KIND = "무근,버림"
|
||||
#: 버림 타설 줄에 **표시**할 이름 — 실무 내역 표기 그대로(「레미콘타설(장비) 무근,버림」).
|
||||
#: ⚠⚠ **표시 문구일 뿐 갈래가 아니다.** 품셈 12-1-1 의 갈래는 무근/철근/소형 셋뿐이고
|
||||
#: **「버림」이라는 열이 없다.** 실무도 줄 이름만 「무근,버림」이고 품은 무근 것을 쓴다
|
||||
#: (봉화 제50호표 단가가 「무근」과 같음).
|
||||
#: ⇒ `variant_value`·`structure_kind` 는 **「무근구조물」 그대로** 보내고 여기 이름은
|
||||
#: `spec` 에만 쓴다. 갈래 축에 없는 값을 보내면 받는 쪽이 단가를 못 고른다
|
||||
#: (2026-09-09 실측: 275,584원이 통째로 빠졌다).
|
||||
BLINDING_PLACING_LABEL = "무근,버림"
|
||||
#: 버림이 실제로 쓰는 품셈 갈래 — 무근이다.
|
||||
BLINDING_PLACING_KIND = "무근구조물"
|
||||
|
||||
|
||||
def _placing_rows(
|
||||
@@ -449,13 +456,20 @@ def _placing_rows(
|
||||
volume = float(component.get("amount") or 0.0)
|
||||
if volume <= 0:
|
||||
continue
|
||||
kind = BLINDING_PLACING_KIND if name == "버림콘크리트" else structure_kind(structure)
|
||||
buckets[kind] = buckets.get(kind, 0.0) + volume
|
||||
# (갈래, 표시 이름) 으로 담는다 — 갈래는 품셈 축, 표시는 실무 줄 이름.
|
||||
if name == "버림콘크리트":
|
||||
key = (BLINDING_PLACING_KIND, BLINDING_PLACING_LABEL)
|
||||
else:
|
||||
kind = structure_kind(structure)
|
||||
key = (kind, kind)
|
||||
buckets[key] = buckets.get(key, 0.0) + volume
|
||||
rows = [
|
||||
{
|
||||
"work_item_code": code,
|
||||
"name": "콘크리트 타설",
|
||||
"spec": kind,
|
||||
# ⚠ 표시 이름과 갈래를 **가른다** — 표시는 실무 줄 이름(「무근,버림」),
|
||||
# 갈래(`variant_value`)는 품셈 축(무근/철근/소형)이라야 단가가 붙는다.
|
||||
"spec": label,
|
||||
"unit": "㎥",
|
||||
"quantity": volume,
|
||||
"quantity_gross": None,
|
||||
@@ -468,7 +482,7 @@ def _placing_rows(
|
||||
"station_from": None,
|
||||
"station_to": None,
|
||||
"excavation_method": None,
|
||||
"spec_detail": kind,
|
||||
"spec_detail": label,
|
||||
"composite_parts": None,
|
||||
"structure_kind": kind,
|
||||
"blocked_kind": None,
|
||||
@@ -485,7 +499,7 @@ def _placing_rows(
|
||||
"in_bill_reason": "",
|
||||
"origin": ORIGIN_STRUCTURE,
|
||||
}
|
||||
for kind, volume in sorted(buckets.items())
|
||||
for (kind, label), volume in sorted(buckets.items())
|
||||
]
|
||||
notes: list[str] = []
|
||||
if rows and used_default:
|
||||
|
||||
@@ -213,6 +213,10 @@ class QuantitySettingsBody(BaseModel):
|
||||
concrete_placing_method: str | None = None
|
||||
# 표토제거 두께(m). 품셈이 정하는 값이 아니라 설계 입력이다(9-15 [주]②).
|
||||
topsoil_thickness_m: float | None = None
|
||||
# 부대시설 개소 — `{항목키: 개소}`(2026-09-09 확정 ⑬).
|
||||
# ⚠ 산식(연장÷500)으로 만들지 않는다 — 임도규정이 「필요시 거리를 조정」이라 하고
|
||||
# 기점 포함·갈림길 중복을 원문이 정하지 않는다. **설계자가 넣는 값**이다.
|
||||
ancillary_counts: dict[str, float] | None = None
|
||||
|
||||
|
||||
#: `None` 이 「안 정함」을 뜻하는 칸 — 저장에서 **버리지 않고 그대로 덮어쓴다**.
|
||||
@@ -262,7 +266,8 @@ async def save_quantity_settings(project_id: UUID, body: QuantitySettingsBody) -
|
||||
_save_quantity,
|
||||
root,
|
||||
values,
|
||||
("rock_methods", "material_supply", "concrete_placing_method") + NULLABLE_SETTING_KEYS,
|
||||
("rock_methods", "material_supply", "concrete_placing_method", "ancillary_counts")
|
||||
+ NULLABLE_SETTING_KEYS,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B08 설정 저장 실패(쓰기): project_id=%s", project_id)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/* =============================================================================
|
||||
* 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.67배)을 더 곱합니다 — 그 계수의 규정 원문(기재부 " +
|
||||
"예정가격 작성기준)을 아직 확인하지 못해 적용하지 않았습니다. 확정되면 " +
|
||||
"기계가 든 공종 단가가 약 19~24% 오릅니다.",
|
||||
),
|
||||
);
|
||||
body.append(note("잡재료(주연료의 %)는 연료 소요량에 포함되어 있습니다 — 따로 세지 않습니다."));
|
||||
}
|
||||
|
||||
/** 두 탭이 함께 쓰는 「아직 못 불러왔습니다」 문구. */
|
||||
export function drawBaseDataError(body: HTMLElement): void {
|
||||
body.append(note(L("B09_Estimation_Tab_Pending")));
|
||||
}
|
||||
@@ -16,6 +16,12 @@
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
drawBaseDataTab,
|
||||
drawMachineTab,
|
||||
fetchBaseData,
|
||||
type BaseDataDto,
|
||||
} from "./B09_Estimation_UI_BaseData";
|
||||
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
@@ -560,10 +566,10 @@ const TAB_KEYS: Array<[string, keyof typeof ui_locales, boolean]> = [
|
||||
["boq", "B09_Estimation_Tab_Boq", true],
|
||||
["unit_price", "B09_Estimation_Tab_UnitPrice", true],
|
||||
["price_basis", "B09_Estimation_Tab_PriceBasis", true],
|
||||
["machine", "B09_Estimation_Tab_Machine", false],
|
||||
["machine", "B09_Estimation_Tab_Machine", true],
|
||||
["duration", "B09_Estimation_Tab_Duration", false],
|
||||
["supply", "B09_Estimation_Tab_Supply", true],
|
||||
["base_data", "B09_Estimation_Tab_BaseData", false],
|
||||
["base_data", "B09_Estimation_Tab_BaseData", true],
|
||||
];
|
||||
|
||||
function buildTabs(active: string, onSelect: (key: string) => void): HTMLElement {
|
||||
@@ -773,6 +779,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
const form: CostFormState = { ...INITIAL_FORM };
|
||||
let activeTab = "cost_sheet";
|
||||
let baseData: BaseDataDto | null = null;
|
||||
let sheet: CostSheetDto | null = null;
|
||||
let unitPriceList: UnitPriceListDto | null = null;
|
||||
let unitPriceDetail: UnitPriceDetailDto | null = null;
|
||||
@@ -1125,6 +1132,29 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
|
||||
drawMaterialTab();
|
||||
return;
|
||||
}
|
||||
if (activeTab === "base_data" || activeTab === "machine") {
|
||||
// 기초자료 네 표 — 없으면 한 번 받아 오고, 받은 뒤 다시 그린다.
|
||||
if (!baseData) {
|
||||
const loading = document.createElement("div");
|
||||
loading.className = "b09-empty";
|
||||
loading.textContent = L("B09_Estimation_Tab_Pending");
|
||||
body.append(loading);
|
||||
if (projectId) {
|
||||
void fetchBaseData(projectId)
|
||||
.then((data) => {
|
||||
baseData = data;
|
||||
drawBody();
|
||||
})
|
||||
.catch(() => {
|
||||
/* 못 받아도 화면을 비우지 않는다 — 위 문구가 그대로 남는다. */
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (activeTab === "machine") drawMachineTab(body, baseData);
|
||||
else drawBaseDataTab(body, baseData);
|
||||
return;
|
||||
}
|
||||
if (activeTab !== "cost_sheet") {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b09-empty";
|
||||
|
||||
Reference in New Issue
Block a user