Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -15,6 +15,8 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토
|
||||
풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트
|
||||
구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 ·
|
||||
지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2).
|
||||
채집석 공제(160-5) = 자재 총괄 「채집석 공제」 값(돌(채집) 둘 · 고임돌 · 막자갈 · 2공구 엑셀
|
||||
토적집계표 H32 = −자재수량집계표!Y68) → 잔토처리 = ROUND(잔토 − 공제) · 공제 줄은 잔토처리 아래.
|
||||
|
||||
짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` ·
|
||||
`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`).
|
||||
@@ -93,8 +95,10 @@ def summary_rows(
|
||||
totals: dict[str, float],
|
||||
haul: dict[str, dict[str, Any]],
|
||||
soft_pct: float = SOFT_ROCK_PCT_DEFAULT,
|
||||
deduct: float | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔."""
|
||||
"""토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔 ·
|
||||
`deduct` = 채집석 공제 ㎥(160-5 · 없으면 빈칸)."""
|
||||
t = totals
|
||||
cut_soft, cut_hard = rock_split(t["cut_rock_volume_m3"], soft_pct)
|
||||
ditch_soft, ditch_hard = rock_split(t["ditch_rock_volume_m3"], soft_pct)
|
||||
@@ -121,7 +125,8 @@ def summary_rows(
|
||||
_row("종방향유용토", "", "백호우", m3, free_q, "유용토"), # 종방향무대운반거리!B25
|
||||
_row("토사운반", "", "BH+DT+BH", m3, dump_q), # 평균운반거리!B42
|
||||
_row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ
|
||||
_row("잔토처리", "", "백호우", m3, r0(t["balance_m3"])), # 토적표!T137
|
||||
_row("잔토처리", "", "백호우", m3, r0(t["balance_m3"] - (deduct or 0))), # 토적표!T137
|
||||
_row("채집석 공제", "", "돌(채집) · 고임돌 · 막자갈", m3, deduct, "잔토처리에서 뺌"),
|
||||
_row("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137
|
||||
_row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8
|
||||
_row("표토제거", "", "", "㎡", None),
|
||||
|
||||
@@ -15,9 +15,12 @@
|
||||
단위수량 기본값(143-4) = 연결된 사본 일위대가 자재 · 로직 재료(`summary.snapshot` · 서버가
|
||||
사본 셈으로 냄 — `B08_Quantity_Router_Material.snapshot_units`) · 자재 열 = 그 사본 자재
|
||||
(키 = MT 코드 · 로직 재료 = 이름|단위) + 관경 · 2공구 고정 열(`MATERIALS`)은 사용자 값이 있을
|
||||
때만 · 콘크리트소요 ·
|
||||
총괄로 끌어옴 = 이름 · 규격 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched`(화면 알림) ·
|
||||
때만(콘크리트(기계) · 모르터 1:3 = 콘크리트소요 원천이라 늘 · 160-4) · 콘크리트소요 ·
|
||||
총괄로 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched` → 총괄 끝에
|
||||
그 이름 · 규격 · 단위 그대로 줄(할증 빈칸 · 끝수 없음 · 160-3) ·
|
||||
사본 없거나 그 자재가 없으면 빈칸 · 관매설 → 파형강관 1 은 그대로 · 손 값이 이김.
|
||||
채집석 공제(160-5) = 돌(채집) 둘 · 고임돌 · 막자갈 계 합(엑셀 Y68 · 끝수 없음) → 총괄 줄 · 토공
|
||||
잔토처리에서 뺌(`B08_Quantity_Engine_Earthwork_Summary.summary_rows`).
|
||||
|
||||
사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만:
|
||||
{"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}},
|
||||
@@ -81,6 +84,11 @@ CONCRETE_ROWS: list[tuple[str, str, str, str | None, dict[str, float | None]]] =
|
||||
("m13", "모르터", "1:3", "mortar_1_3", {"cement": 510, "sand": 1.10, "gravel": None}),
|
||||
]
|
||||
MIX_FIELDS = ("cement", "sand", "gravel")
|
||||
#: 늘 보이는 2공구 고정 열 — 콘크리트소요가 끌어오는 두 열(엑셀 I66 · R66 · 160-4)
|
||||
ALWAYS = ("concrete_machine", "mortar_1_3")
|
||||
#: 채집석 공제(160-5) — 현장 채집재 넷 → 총괄로 안 보내고 합을 토공 잔토처리에서 뺌(엑셀 Y68)
|
||||
PICKED = ("stone_pick_35", "stone_pick_45", "bed_stone", "rubble")
|
||||
DEDUCT_KEY = "picked_deduct"
|
||||
|
||||
#: 총괄자재수량집계표 줄 — [키, 구분, 규격, 단위, 끌어올 곳, 할증 % 기본, 끝수]
|
||||
# 끌어올 곳 = "m:{자재}"(자재수량집계표 계) · "c:{cement|sand|gravel}"(콘크리트소요 계)
|
||||
@@ -201,7 +209,7 @@ def material_columns(
|
||||
for entry in MATERIALS:
|
||||
if entry[0] == "PIPES":
|
||||
out += [(f"pipe_{d}", "파형강관", f"Φ{d}", "m") for d in sizes]
|
||||
elif shown is None or entry[0] in shown:
|
||||
elif shown is None or entry[0] in shown or entry[0] in ALWAYS:
|
||||
out.append(entry)
|
||||
have = {m[0] for m in out}
|
||||
for m in extra or []:
|
||||
@@ -214,13 +222,23 @@ def _norm(text: str) -> str:
|
||||
return re.sub(r"\s+", "", str(text or "")).lower()
|
||||
|
||||
|
||||
def matched(name: str, spec: str) -> str | None:
|
||||
def _unit(text: str) -> str:
|
||||
"""단위 맞춤 글 — 빈칸 · 대소문 무시 · m3 = ㎥ · m2 = ㎡."""
|
||||
return _norm(text).replace("m3", "㎥").replace("m2", "㎡")
|
||||
|
||||
|
||||
def matched(name: str, spec: str, unit: str) -> str | None:
|
||||
"""사본 자재 → 2공구 고정 열 키(콘크리트소요 · 총괄 끌어옴) — 이름에 고정 이름 · 규격이 다
|
||||
듦 · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이 실무 이름과 다르면 「맞춤 없음」(맞춤 표는
|
||||
필요할 때)."""
|
||||
듦 + 단위 같음(kg ↔ ton 은 맞춤 아님 · 160-3) · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이
|
||||
실무 이름과 다르면 「맞춤 없음」(총괄 끝 줄 · 맞춤 표는 146-3)."""
|
||||
text = _norm(f"{name}{spec}")
|
||||
for key, fixed, fixed_spec, _ in MATERIALS:
|
||||
if key != "PIPES" and _norm(fixed) in text and _norm(fixed_spec) in text:
|
||||
for key, fixed, fixed_spec, fixed_unit in MATERIALS:
|
||||
if (
|
||||
key != "PIPES"
|
||||
and _norm(fixed) in text
|
||||
and _norm(fixed_spec) in text
|
||||
and _unit(unit) == _unit(fixed_unit)
|
||||
):
|
||||
return key
|
||||
return None
|
||||
|
||||
@@ -286,10 +304,10 @@ def compute(
|
||||
fixed = {m[0] for m in MATERIALS} | {f"pipe_{d}" for d in sizes}
|
||||
pulled: dict[str, float] = {}
|
||||
unmatched: list[str] = []
|
||||
for key, name, spec, _ in materials:
|
||||
for key, name, spec, unit in materials:
|
||||
if key not in sums:
|
||||
continue
|
||||
target = key if key in fixed else matched(name, spec)
|
||||
target = key if key in fixed else matched(name, spec, unit)
|
||||
if target is None:
|
||||
unmatched.append(key)
|
||||
else:
|
||||
@@ -337,20 +355,29 @@ def compute(
|
||||
]
|
||||
else:
|
||||
plan.append(entry)
|
||||
# 채집석 공제(160-5) · 맞춤 없는 사본 자재(160-3) — 끝수 없음(r6) · 할증 기본 빈칸
|
||||
plan.append(
|
||||
(DEDUCT_KEY, "채집석 공제", "돌(채집) · 고임돌 · 막자갈", "㎥", "picked", None, "r6")
|
||||
)
|
||||
by_key = {m[0]: m for m in materials}
|
||||
plan += [(k, by_key[k][1], by_key[k][2], by_key[k][3], f"m:{k}", None, "r6") for k in unmatched]
|
||||
picked = [pulled[k] for k in PICKED if k in pulled]
|
||||
total_rows = []
|
||||
for key, group, spec, unit, source, pct_default, rule in plan:
|
||||
ref, _, divisor = source.partition("/")
|
||||
kind, _, name = ref.partition(":")
|
||||
qty: float | None = None
|
||||
if kind == "m":
|
||||
qty = pulled.get(name)
|
||||
qty = pulled.get(name, sums.get(name) if name in unmatched else None)
|
||||
elif kind == "picked":
|
||||
qty = round6(sum(picked)) if picked else None
|
||||
elif kind == "c":
|
||||
qty = concrete_total.get(name)
|
||||
elif kind == "cp":
|
||||
qty = couplings.get(int(name))
|
||||
if qty is not None and divisor:
|
||||
qty = round6(qty / float(divisor))
|
||||
pct = _num(_pick(surcharge_in, key, pct_default))
|
||||
pct = None if key == DEDUCT_KEY else _num(_pick(surcharge_in, key, pct_default))
|
||||
amount = finish(qty * (1 + (pct or 0) / 100), rule) if qty is not None else None
|
||||
total_rows.append(
|
||||
{
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
|
||||
* 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬)
|
||||
* 단위수량 기본값(143-4) = 서버가 낸 사본 자재(`summary.snapshot`) · 자재 열 = 사본 자재 + 관경 ·
|
||||
* 2공구 고정 열은 사용자 값 있을 때만 · 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤(`matched`)
|
||||
* 2공구 고정 열은 사용자 값 있을 때만(콘크리트(기계) · 모르터 1:3 은 늘 · 160-4) ·
|
||||
* 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 맞춤 없음 = 총괄 끝 줄(160-3)
|
||||
* 채집석 공제(160-5) = 채집재 넷 계 합 → 총괄 줄 · 토공 잔토처리에서 뺌
|
||||
* ========================================================================== */
|
||||
|
||||
export type Cells = Record<string, number | null>;
|
||||
@@ -77,7 +79,7 @@ export interface MaterialSheets {
|
||||
materials: MaterialColumn[];
|
||||
rows: QuantityRow[];
|
||||
total: Record<string, number>;
|
||||
/** 2공구 고정 열에 안 맞아 콘크리트소요 · 총괄로 못 끌어온 사본 자재 키(143-4) */
|
||||
/** 2공구 고정 열에 안 맞은 사본 자재 키(143-4) — 총괄 끝에 그대로 줄(160-3) */
|
||||
unmatched: string[];
|
||||
};
|
||||
concrete: { rows: ConcreteRow[]; total: Record<string, number> };
|
||||
@@ -122,6 +124,11 @@ const CONCRETE_ROWS: Array<[string, string, string, string | null, Cells]> = [
|
||||
["m13", "모르터", "1:3", "mortar_1_3", { cement: 510, sand: 1.1, gravel: null }],
|
||||
];
|
||||
export const MIX_FIELDS = ["cement", "sand", "gravel"] as const;
|
||||
/** 늘 보이는 2공구 고정 열 — 콘크리트소요 원천(엑셀 I66 · R66 · 160-4) · 파이썬 짝 ALWAYS */
|
||||
const ALWAYS = ["concrete_machine", "mortar_1_3"];
|
||||
/** 채집석 공제(160-5) — 채집재 넷 계 합(엑셀 Y68) · 파이썬 짝 PICKED · DEDUCT_KEY */
|
||||
const PICKED = ["stone_pick_35", "stone_pick_45", "bed_stone", "rubble"];
|
||||
export const DEDUCT_KEY = "picked_deduct";
|
||||
|
||||
/** 좌측 패널 할증(PLAN 124) — 총괄 모래 · 자갈 · 시멘트 줄 · 기본 = 1-3-1 현장비빔(기타) 12 · 5 · 3 */
|
||||
export const PANEL_SURCHARGE: Record<string, number> = { sand: 12, gravel: 5, cement: 3 };
|
||||
@@ -216,7 +223,7 @@ function materialColumns(
|
||||
const out = MATERIALS.flatMap((entry): Entry[] =>
|
||||
entry[0] === "PIPES"
|
||||
? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"])
|
||||
: shown.has(entry[0])
|
||||
: shown.has(entry[0]) || ALWAYS.includes(entry[0])
|
||||
? [entry]
|
||||
: [],
|
||||
);
|
||||
@@ -230,11 +237,20 @@ const norm = (text: string): string =>
|
||||
.replace(/\s+/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 · 첫 맞춤(파이썬 짝 `matched`) */
|
||||
export function matched(name: string, spec: string): string | null {
|
||||
/** 단위 맞춤 글 — 빈칸 · 대소문 무시 · m3 = ㎥ · m2 = ㎡(파이썬 짝 `_unit`) */
|
||||
const unitOf = (text: string): string => norm(text).replace(/m3/g, "㎥").replace(/m2/g, "㎡");
|
||||
|
||||
/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 + 단위 같음 · 첫 맞춤(파이썬 짝 `matched`) */
|
||||
export function matched(name: string, spec: string, unit: string): string | null {
|
||||
const text = norm(`${name}${spec}`);
|
||||
for (const [key, fixed, fixedSpec] of MATERIALS)
|
||||
if (key !== "PIPES" && text.includes(norm(fixed)) && text.includes(norm(fixedSpec))) return key;
|
||||
for (const [key, fixed, fixedSpec, fixedUnit] of MATERIALS)
|
||||
if (
|
||||
key !== "PIPES" &&
|
||||
text.includes(norm(fixed)) &&
|
||||
text.includes(norm(fixedSpec)) &&
|
||||
unitOf(unit) === unitOf(fixedUnit)
|
||||
)
|
||||
return key;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -291,9 +307,9 @@ export function computeMaterialSheets(
|
||||
const fixed = new Set([...MATERIALS.map((m) => m[0]), ...sizes.map((d) => `pipe_${d}`)]);
|
||||
const pulled: Record<string, number> = {};
|
||||
const unmatched: string[] = [];
|
||||
for (const [key, name, spec] of materials) {
|
||||
for (const [key, name, spec, unit] of materials) {
|
||||
if (!(key in sums)) continue;
|
||||
const target = fixed.has(key) ? key : matched(name, spec);
|
||||
const target = fixed.has(key) ? key : matched(name, spec, unit);
|
||||
if (target === null) unmatched.push(key);
|
||||
else add(pulled, target, sums[key]);
|
||||
}
|
||||
@@ -350,19 +366,30 @@ export function computeMaterialSheets(
|
||||
]
|
||||
: [entry],
|
||||
);
|
||||
// 채집석 공제(160-5) · 맞춤 없는 사본 자재(160-3) — 끝수 없음(r6) · 할증 기본 빈칸
|
||||
plan.push([DEDUCT_KEY, "채집석 공제", "돌(채집) · 고임돌 · 막자갈", "㎥", "picked", null, "r6"]);
|
||||
for (const k of unmatched) {
|
||||
const [, name, spec, unit] = materials.find((m) => m[0] === k)!;
|
||||
plan.push([k, name, spec, unit, `m:${k}`, null, "r6"]);
|
||||
}
|
||||
const picked = PICKED.filter((k) => k in pulled).map((k) => pulled[k]);
|
||||
const totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, rule]) => {
|
||||
const [ref, divisor = ""] = source.split("/");
|
||||
const [kind, name = ""] = ref.split(":");
|
||||
let qty =
|
||||
kind === "m"
|
||||
? (pulled[name] ?? null)
|
||||
: kind === "c"
|
||||
? (concreteTotal[name] ?? null)
|
||||
: kind === "cp"
|
||||
? (couplings.get(Number(name)) ?? null)
|
||||
: null;
|
||||
? (pulled[name] ?? (unmatched.includes(name) ? sums[name] : null))
|
||||
: kind === "picked"
|
||||
? picked.length
|
||||
? round6(picked.reduce((a, b) => a + b, 0))
|
||||
: null
|
||||
: kind === "c"
|
||||
? (concreteTotal[name] ?? null)
|
||||
: kind === "cp"
|
||||
? (couplings.get(Number(name)) ?? null)
|
||||
: null;
|
||||
if (qty !== null && divisor) qty = round6(qty / Number(divisor));
|
||||
const pct = num(pick(surchargeIn, key, pctDefault));
|
||||
const pct = key === DEDUCT_KEY ? null : num(pick(surchargeIn, key, pctDefault));
|
||||
const amount = qty !== null ? finish(qty * (1 + (pct ?? 0) / 100), rule) : null;
|
||||
return {
|
||||
key,
|
||||
|
||||
@@ -160,18 +160,20 @@ export function recountRockC(
|
||||
/** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */
|
||||
const excelRound = (value: number): number => Math.sign(value) * Math.round(Math.abs(value));
|
||||
|
||||
/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로). */
|
||||
/** 토공집계표 보정량계 · 유용토 소계 · 횡방향유용토 · 잔토처리 줄 — 합계에서 다시(종방향 무대 Q 는 그대로) ·
|
||||
* 잔토처리 = 잔토 − 「채집석 공제」 줄 값(160-5 · 서버가 낸 값 그대로). */
|
||||
export function recountAdjustedSummary<R extends { group: string; value: number | null }>(
|
||||
rows: readonly R[],
|
||||
totals: Record<string, number>,
|
||||
freeQ: number,
|
||||
): R[] {
|
||||
const diverted = totals.diverted_m3 ?? 0;
|
||||
const deduct = rows.find((row) => row.group === "채집석 공제")?.value ?? 0;
|
||||
const values: Record<string, number> = {
|
||||
보정량계: excelRound(totals.adjusted_total_m3 ?? 0),
|
||||
"유용토 소계": diverted + freeQ,
|
||||
횡방향유용토: diverted,
|
||||
잔토처리: excelRound(totals.balance_m3 ?? 0),
|
||||
잔토처리: excelRound((totals.balance_m3 ?? 0) - deduct),
|
||||
};
|
||||
return rows.map((row) => (row.group in values ? { ...row, value: values[row.group] } : row));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)` · 암 = 연암 · 경암 비율 가중 C ·
|
||||
`rock_c` = 가중 전 리핑암 · 발파암 C(조작 중 브라우저가 다시 가중 · 125-1).
|
||||
암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표.
|
||||
잔토처리 = 잔토 − 채집석 공제(자재 총괄 저장본 · `picked_deduction` · 160-5).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -32,6 +33,7 @@ from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
|
||||
summary_rows,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B08_Quantity.B08_Quantity_Router_Material import picked_deduction
|
||||
from common_util.common_util_project_settings import (
|
||||
earthwork_conversion_factors,
|
||||
mixed_conversion_factors,
|
||||
@@ -63,9 +65,10 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]:
|
||||
split_rock_columns(record, soft)
|
||||
mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {}
|
||||
haul = haul_lists(mass_haul.get("haul_plan"))
|
||||
deduct = await picked_deduction(project_id, root)
|
||||
return {
|
||||
**table,
|
||||
"summary": summary_rows(table["totals"], haul, soft),
|
||||
"summary": summary_rows(table["totals"], haul, soft, deduct),
|
||||
"rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft},
|
||||
"rock_c": {
|
||||
kind: entry["compacted"]
|
||||
|
||||
@@ -9,12 +9,15 @@
|
||||
좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options)
|
||||
기본값은 파일에 안 씀(읽을 때 채움).
|
||||
단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 ·
|
||||
로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산).
|
||||
로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산) · 참조 줄(승격 · 사전조합)
|
||||
안 재료도 셈 · 기계 주연료 · 할증(ℓ)은 뺌(원가 몫 · 160-3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -22,6 +25,7 @@ from uuid import UUID
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import (
|
||||
DEDUCT_KEY,
|
||||
PANEL_KEY,
|
||||
_num,
|
||||
clean_overrides,
|
||||
@@ -37,19 +41,75 @@ from common_util.common_util_summary_item import GROUP_JOIN
|
||||
router = APIRouter(prefix="/api/projects", tags=["B08 Material"])
|
||||
|
||||
SETTINGS_KEY = "material_sheets"
|
||||
#: 기계 주연료 · 할증 · 잡유(ℓ) — 기계경비(원가) 몫 · 자재 표에서 뺌(160-3).
|
||||
# ponytail: 이름 글 맞춤 — 새 연료 이름이 나오면 여기에 더함
|
||||
FUEL = re.compile(r"연료|경유|휘발유|중유|등유|잡유|gasoline", re.IGNORECASE)
|
||||
|
||||
|
||||
def _fuel(name: str, unit: str) -> bool:
|
||||
return unit in ("ℓ", "L") and FUEL.search(name) is not None
|
||||
|
||||
|
||||
def _qty(value: Any) -> float | None:
|
||||
return _num(float(value) if isinstance(value, Decimal) else value)
|
||||
|
||||
|
||||
def _ref_mats(whole: Any, read: Any, ref: dict, trail: tuple[str, ...] = ()) -> list[tuple]:
|
||||
"""참조 문서 1 단위 재료 `[(재료 줄, 수량)]` — 층 셈 펼침(`M02_Labor_Ref.Expand`)과 같은 길:
|
||||
승격 = 원 로직 재료 ÷ 배 · 사전조합 = 안 줄 재료 × 손 수량(빈칸 1) · 로직 줄은 알약 배로 나눔 ·
|
||||
순환 · 깊이 넘음 · 없는 문서 = 빈 것."""
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
|
||||
rid = refs.ref_id(ref)
|
||||
doc = read(ref)
|
||||
if rid in trail or len(trail) >= refs.DEPTH or not isinstance(doc, dict):
|
||||
return []
|
||||
|
||||
def run(one: dict, times: Any) -> list[tuple]:
|
||||
got = labor.run_line(
|
||||
whole,
|
||||
str(one.get("로직") or ""),
|
||||
one.get("입력값"),
|
||||
one.get("켜쌓기율"),
|
||||
one.get("고름"),
|
||||
)
|
||||
div = _qty(times) or 1.0
|
||||
return [(m, q / div) for m in got.get("재료") or [] if (q := _qty(m.get("수량")))]
|
||||
|
||||
lines = [one for one in doc.get("담은로직") or [] if isinstance(one, dict)]
|
||||
if refs.kind_of(ref["키"]) == "promote":
|
||||
return run(lines[0], doc.get("배")) if lines else []
|
||||
kept = doc.get("알약")
|
||||
pills = kept if isinstance(kept, list) else combo_store.combo_pills({"담은로직": lines})["물량"]
|
||||
by_logic = {code: pill for pill in pills for code in pill.get("로직") or []}
|
||||
out: list[tuple] = []
|
||||
for one in lines:
|
||||
if isinstance(one.get("참조"), dict):
|
||||
sub = _ref_mats(whole, read, one["참조"], (*trail, rid))
|
||||
elif one.get("로직"):
|
||||
sub = run(one, (by_logic.get(one["로직"]) or {}).get("배"))
|
||||
else:
|
||||
continue
|
||||
hand = _qty((one.get("수량") or {}).get("손"))
|
||||
out += [(m, q * (1.0 if hand is None else hand)) for m, q in sub]
|
||||
return out
|
||||
|
||||
|
||||
def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[str, float]:
|
||||
"""사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료
|
||||
(키 = 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) · 줄 수량 빈칸 · 0 · 파형강관은 안 담음 · 셈 못 하면
|
||||
빈 것. ponytail: 참조 줄(승격 ·
|
||||
사전조합) 안 재료는 안 셈 — 펼친 재료가 셈 답에 오면 그때."""
|
||||
(키 = 고른 품목 MT 코드 · 없으면 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) + 참조 줄 안 재료
|
||||
(160-3) · 줄 수량 빈칸 · 0 · 파형강관 · 기계 연료는 안 담음 · 셈 못 하면 빈 것."""
|
||||
from M02_MasterTemplete import M02_Item_Copy as copies
|
||||
from M02_MasterTemplete.M02_Item_Source import PRICE_FILE, read_json
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete.M02_Item_Prices import load_prices
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
|
||||
|
||||
try:
|
||||
got = copies.calc(root, folder)
|
||||
bundle = read_json(copies.folder_of(root, folder) / PRICE_FILE) or {}
|
||||
labor = read_json(copies.folder_of(root, folder) / LABOR_FILE) or {}
|
||||
except (LookupError, ValueError):
|
||||
return {}
|
||||
rows = {
|
||||
@@ -67,12 +127,35 @@ def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[
|
||||
meta.setdefault(key, {"key": key, "name": name, "spec": spec, "unit": unit})
|
||||
out[key] = out.get(key, 0.0) + qty
|
||||
|
||||
def logic_mat(mat: dict, qty: float) -> None:
|
||||
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "")
|
||||
if _fuel(name, unit):
|
||||
return
|
||||
goods = mat.get("품목")
|
||||
code = str(goods.get("키") or "") if isinstance(goods, dict) else ""
|
||||
if row := rows.get(code) if code else None: # 고른 품목 = 이름 · 규격 맞춤(160-3)
|
||||
put(code, str(row.get("이름") or name), str(row.get("규격") or ""), unit, qty)
|
||||
else:
|
||||
put(f"{name}|{unit}", name, "", unit, qty)
|
||||
|
||||
frozen = (labor.get("참조사본") if isinstance(labor, dict) else None) or {}
|
||||
whole = None # 사본 단가 — 참조 줄이 있을 때만 읽음
|
||||
|
||||
def read(ref: dict) -> Any:
|
||||
return frozen.get(refs.ref_id(ref))
|
||||
|
||||
for line in got.get("줄") or []:
|
||||
qty, times = _num(line.get("수량")), _num(line.get("배")) or 1.0
|
||||
for mat in line.get("재료") or [] if qty is not None else []:
|
||||
if qty is None:
|
||||
continue
|
||||
if isinstance(line.get("참조"), dict): # 참조 줄 = 배 1
|
||||
whole = load_prices(bundle) if whole is None else whole
|
||||
for mat, per in _ref_mats(whole, read, line["참조"]):
|
||||
logic_mat(mat, per * qty)
|
||||
continue
|
||||
for mat in line.get("재료") or []:
|
||||
if (per := _num(mat.get("수량"))) is not None:
|
||||
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "")
|
||||
put(f"{name}|{unit}", name, "", unit, per * qty / times)
|
||||
logic_mat(mat, per * qty / times)
|
||||
for mat in got.get("자재") or []:
|
||||
code, qty = str(mat.get("코드") or ""), _num(mat.get("수량"))
|
||||
if code and qty is not None:
|
||||
@@ -126,9 +209,12 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any])
|
||||
sheets = compute(source, overrides, panel)
|
||||
notes = list(summary.get("notes") or [])
|
||||
if lost := sheets["quantity"]["unmatched"]:
|
||||
notes.append(
|
||||
f"사본 자재 {len(lost)}종 — 2공구 열 맞춤 없음(콘크리트소요 · 총괄로 안 끌어옴)"
|
||||
names = {m["key"]: m for m in sheets["quantity"]["materials"]}
|
||||
listed = " · ".join(
|
||||
" ".join(filter(None, (names[k]["name"], names[k]["spec"], names[k]["unit"])))
|
||||
for k in lost
|
||||
)
|
||||
notes.append(f"2공구 열 맞춤 없음 {len(lost)}종(총괄 끝 줄 그대로) — {listed}")
|
||||
return {
|
||||
"source": source,
|
||||
"overrides": overrides,
|
||||
@@ -139,6 +225,13 @@ async def _sheets(project_id: UUID, root: str | Path, overrides: dict[str, Any])
|
||||
}
|
||||
|
||||
|
||||
async def picked_deduction(project_id: UUID, root: str | Path) -> float | None:
|
||||
"""채집석 공제 ㎥(160-5) — 저장본 기준 총괄 「채집석 공제」 줄 값 · 토공 잔토처리에서 뺌."""
|
||||
overrides = clean_overrides(quantity_settings(root).get(SETTINGS_KEY))
|
||||
rows = (await _sheets(project_id, root, overrides))["sheets"]["material"]["rows"]
|
||||
return next((row["amount"] for row in rows if row["key"] == DEDUCT_KEY), None)
|
||||
|
||||
|
||||
@router.get("/{project_id}/quantity/materials")
|
||||
async def material_sheets(project_id: UUID) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈)
|
||||
* [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
|
||||
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3)
|
||||
* 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른
|
||||
* 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5)
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
@@ -23,6 +25,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
blankUnitRows,
|
||||
computeMaterialSheets,
|
||||
DEDUCT_KEY,
|
||||
MIX_FIELDS,
|
||||
type Cells,
|
||||
type MaterialOverrides,
|
||||
@@ -49,7 +52,8 @@ const HINTS: Record<MaterialSheetId, string> = {
|
||||
material:
|
||||
"합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
|
||||
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
|
||||
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1",
|
||||
quantity:
|
||||
"수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요",
|
||||
};
|
||||
const HIDDEN = "값 없는 줄은 숨김";
|
||||
|
||||
@@ -157,6 +161,34 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> =
|
||||
gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"],
|
||||
};
|
||||
|
||||
/** 2공구 엑셀 손값(조사 I) — 지금 값(품셈 근거 · 124)과 다르면 표 아래 알림 한 줄만(160-4) */
|
||||
const EXCEL_MIX: Record<string, Cells> = {
|
||||
c25: { cement: 346, sand: 0.51, gravel: 0.59 },
|
||||
c40: { cement: 323, sand: 0.48, gravel: 0.65 },
|
||||
c40_plain: { cement: 312, sand: 0.46, gravel: 0.7 },
|
||||
m12: { cement: 680, sand: 0.98 },
|
||||
m13: { cement: 510, sand: 1.1 },
|
||||
};
|
||||
const EXCEL_SURCHARGE: Cells = { sand: 6, gravel: 4, cement: 2, mixed_aggregate: 2 };
|
||||
|
||||
function excelDiffs(s: MaterialSheets): string {
|
||||
const out: string[] = [];
|
||||
for (const row of s.concrete.rows)
|
||||
for (const f of MIX_FIELDS) {
|
||||
const want = EXCEL_MIX[row.key]?.[f];
|
||||
if (want != null && row.per[f] !== want)
|
||||
out.push(
|
||||
`${row.name} ${row.spec} ${MIX_LABEL[f][0]} ${row.per[f] ?? "빈칸"}(엑셀 ${want})`,
|
||||
);
|
||||
}
|
||||
for (const row of s.material.rows) {
|
||||
const want = EXCEL_SURCHARGE[row.key];
|
||||
if (want != null && (row.surcharge ?? 0) !== want)
|
||||
out.push(`${row.spec} 할증 ${row.surcharge ?? 0}%(엑셀 ${want}%)`);
|
||||
}
|
||||
return out.length ? `2공구 엑셀 손값과 다름 — ${out.join(" · ")} · 지금 값 = 품셈 근거` : "";
|
||||
}
|
||||
|
||||
function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows, total } = s.concrete;
|
||||
const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts));
|
||||
@@ -205,6 +237,7 @@ function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
|
||||
function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
const { rows } = s.material;
|
||||
const lost = new Set(s.quantity.unmatched);
|
||||
const keep = shown(rows, (row) => row.qty != null || row.amount != null);
|
||||
return {
|
||||
columns: [
|
||||
@@ -225,12 +258,19 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
|
||||
qty: row.qty,
|
||||
surcharge: row.surcharge,
|
||||
amount: row.amount,
|
||||
note: row.panel ? "할증 = 좌측 패널" : "",
|
||||
note: row.panel
|
||||
? "할증 = 좌측 패널"
|
||||
: row.key === DEDUCT_KEY
|
||||
? "토공 잔토처리에서 뺌"
|
||||
: lost.has(row.key)
|
||||
? "2공구 맞춤 없음"
|
||||
: "",
|
||||
};
|
||||
}),
|
||||
total: null,
|
||||
frozen: 2,
|
||||
editable: (i, key) => key === "surcharge" && !rows[keep[i]].panel,
|
||||
editable: (i, key) =>
|
||||
key === "surcharge" && !rows[keep[i]].panel && rows[keep[i]].key !== DEDUCT_KEY,
|
||||
target: (i) => [o.surcharge, rows[keep[i]].key, null],
|
||||
};
|
||||
}
|
||||
@@ -312,11 +352,15 @@ export async function renderMaterialSheet(
|
||||
},
|
||||
});
|
||||
table.root.dataset.b08Material = sheet;
|
||||
const excel = note("");
|
||||
excel.dataset.b08MaterialExcel = "";
|
||||
const redraw = (): void => {
|
||||
built = BUILD[sheet](sheets, overrides);
|
||||
table.setRows(built.rows, built.total);
|
||||
excel.textContent = sheet === "quantity" ? "" : excelDiffs(sheets);
|
||||
};
|
||||
host.replaceChildren(createCard({ body: [toolbar, table.root] }));
|
||||
redraw();
|
||||
host.replaceChildren(createCard({ body: [toolbar, table.root, excel] }));
|
||||
onOptionsChange(host, () => {
|
||||
sheets = calc();
|
||||
redraw();
|
||||
|
||||
@@ -26,5 +26,5 @@ def test_자재_셋_값_없는_줄_숨김():
|
||||
|
||||
|
||||
def test_표_넷_공용_카드_안():
|
||||
assert "createCard({ body: [toolbar, table.root] })" in MATERIAL
|
||||
assert "createCard({ body: [toolbar, table.root, excel] })" in MATERIAL # + 엑셀 알림(160-4)
|
||||
assert "createCard({ body: parts })" in SUMMARY
|
||||
|
||||
@@ -53,7 +53,16 @@ def test_사본_자재가_열과_단위수량_기본():
|
||||
over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}})
|
||||
sheets = compute(SUMMARY, over)
|
||||
keys = [m["key"] for m in sheets["quantity"]["materials"]]
|
||||
assert keys == ["pipe_1000", "sod", "MT000001", "고임돌|㎥", "야면석|㎥"] # 고정 열 = 손 값만
|
||||
# 고정 열 = 손 값만 · 콘크리트소요 원천 둘은 늘(160-4)
|
||||
assert keys == [
|
||||
"concrete_machine",
|
||||
"pipe_1000",
|
||||
"mortar_1_3",
|
||||
"sod",
|
||||
"MT000001",
|
||||
"고임돌|㎥",
|
||||
"야면석|㎥",
|
||||
]
|
||||
rows = {row["id"]: row for row in sheets["quantity"]["rows"]}
|
||||
revet = rows["pipe:revet|돌쌓기(찰)|2.0"]
|
||||
assert revet["units"]["MT000001"] == 0.31 and revet["amounts"]["MT000001"] == 3.1
|
||||
@@ -62,6 +71,13 @@ def test_사본_자재가_열과_단위수량_기본():
|
||||
assert rows[LEN]["units"]["pipe_1000"] == 1 # 관매설 → 파형강관 1 그대로
|
||||
# 끌어옴 — 레미콘 25-18-80 · 고임돌(채집) = 이름 · 규격 맞춤 · 야면석은 맞춤 없음
|
||||
assert sheets["quantity"]["unmatched"] == ["야면석|㎥"]
|
||||
last = sheets["material"]["rows"][-1] # 맞춤 없음 = 총괄 끝 줄 그대로(160-3)
|
||||
assert (last["key"], last["group"], last["unit"], last["qty"]) == (
|
||||
"야면석|㎥",
|
||||
"야면석",
|
||||
"㎥",
|
||||
15,
|
||||
)
|
||||
total = {row["key"]: row for row in sheets["material"]["rows"]}
|
||||
assert total["remicon_25_18_80"]["qty"] == 3.1
|
||||
assert total["pipe_1000"]["qty"] == 20
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""160-3 B08 자재수량 → 총괄 — 맞춤 없는 사본 자재 = 총괄 끝 줄 · 맞춤은 규격 · 단위까지 ·
|
||||
기계 주연료 · 할증(ℓ) 뺌 · 참조 줄(승격 · 사전조합) 안 재료 셈 · 고른 품목 = MT 코드 · 거울.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from decimal import Decimal
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Material as router
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute, matched
|
||||
from M02_MasterTemplete import M02_Item_Copy as copies
|
||||
from M02_MasterTemplete import M02_Item_Prices as item_prices
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete.M02_Labor_Ref import ref_id
|
||||
from resources.tester.test_121_b08_material_sheets import _run_ts
|
||||
|
||||
REVET = "pipe:revet|돌쌓기(찰)|2.0"
|
||||
SUMMARY = {
|
||||
"columns": [{"id": REVET, "group": ["흙막이", "찰쌓기", "H=2.0"], "label": "m"}],
|
||||
"total": {REVET: 10},
|
||||
"snapshot": {
|
||||
"materials": [
|
||||
{"key": "MT1", "name": "레미콘", "spec": "25-18-80", "unit": "m3"},
|
||||
{"key": "MT2", "name": "이형철근", "spec": "D13", "unit": "ton"}, # 단위 다름
|
||||
{"key": "부직포|㎡", "name": "토목용부직포", "spec": "", "unit": "㎡"},
|
||||
],
|
||||
"units": {REVET: {"MT1": 0.31, "MT2": 0.002, "부직포|㎡": 2.871}},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_맞춤은_이름_규격_단위():
|
||||
assert matched("레미콘", "25-18-80", "㎥") == "remicon_25_18_80"
|
||||
assert matched("레미콘", "25-18-80", "M3") == "remicon_25_18_80" # m3 = ㎥
|
||||
assert matched("이형철근", "D13", "ton") is None # kg ↔ ton 은 맞춤 아님
|
||||
assert matched("호안블럭", "", "㎥") is None # 규격 빈 고정 열도 단위는 봄
|
||||
|
||||
|
||||
def test_맞춤_없는_자재는_총괄_끝_줄_그대로():
|
||||
sheets = compute(SUMMARY, clean_overrides({"surcharge": {"부직포|㎡": 10}}))
|
||||
assert sheets["quantity"]["unmatched"] == ["MT2", "부직포|㎡"]
|
||||
rows = sheets["material"]["rows"]
|
||||
tail = {row["key"]: row for row in rows[-2:]}
|
||||
steel, cloth = tail["MT2"], tail["부직포|㎡"]
|
||||
assert (steel["group"], steel["spec"], steel["unit"], steel["qty"]) == (
|
||||
"이형철근",
|
||||
"D13",
|
||||
"ton",
|
||||
0.02,
|
||||
)
|
||||
assert steel["surcharge"] is None and steel["amount"] == 0.02 # 할증 빈칸 · 끝수 없음
|
||||
assert cloth["qty"] == 28.71 and cloth["amount"] == 31.581 # 손 할증 10 %
|
||||
total = {row["key"]: row for row in rows}
|
||||
assert total["remicon_25_18_80"]["qty"] == 3.1 # 맞춤 = 고정 줄로
|
||||
|
||||
|
||||
def test_거울_TS와_파이썬이_같다():
|
||||
over = clean_overrides({"surcharge": {"부직포|㎡": 10}})
|
||||
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
|
||||
|
||||
|
||||
def test_알림에_맞춤_없는_이름(tmp_path, monkeypatch):
|
||||
async def summary(_project_id):
|
||||
return {**SUMMARY, "notes": []}
|
||||
|
||||
monkeypatch.setattr(router, "structure_summary", summary)
|
||||
monkeypatch.setattr(router, "snapshot_units", lambda root, s: SUMMARY["snapshot"])
|
||||
got = asyncio.run(router._sheets("p", tmp_path, clean_overrides({})))
|
||||
assert got["notes"] == [
|
||||
"2공구 열 맞춤 없음 2종(총괄 끝 줄 그대로) — 이형철근 D13 ton · 토목용부직포 ㎡"
|
||||
]
|
||||
|
||||
|
||||
SC = {"층": "project", "주인": None, "키": "SC-000001"}
|
||||
SP = {"층": "project", "주인": None, "키": "SP-000001"}
|
||||
|
||||
|
||||
def test_참조_줄_재료_연료_뺌_고른_품목(tmp_path, monkeypatch):
|
||||
"""사전조합(로직 L1 손 2 · 알약 배 10 + 승격) · 승격(L2 · 배 4) 안 재료 · 연료(ℓ) 뺌."""
|
||||
fake = {
|
||||
"줄": [
|
||||
{"수량": 2, "배": 1, "참조": SC},
|
||||
{"수량": 3, "배": 1, "참조": SP},
|
||||
{
|
||||
"수량": 4,
|
||||
"배": 2,
|
||||
"재료": [
|
||||
{"이름": "주연료 할증", "단위": "ℓ", "수량": 5},
|
||||
{"이름": "페인트", "단위": "ℓ", "수량": 1},
|
||||
{"이름": "레미콘", "단위": "㎥", "수량": 0.5, "품목": {"키": "MT9"}},
|
||||
],
|
||||
},
|
||||
],
|
||||
"자재": [],
|
||||
}
|
||||
price = {"묶음": {"줄": [{"키": "MT9", "이름": "레디믹스트 레미콘", "규격": "25-18-80"}]}}
|
||||
frozen = {
|
||||
ref_id(SC): {
|
||||
"담은로직": [{"로직": "L1", "수량": {"손": 2}}, {"참조": SP}],
|
||||
"알약": [{"로직": ["L1"], "배": 10}],
|
||||
},
|
||||
ref_id(SP): {"담은로직": [{"로직": "L2"}], "배": 4},
|
||||
}
|
||||
mats = {
|
||||
"L1": [{"이름": "모래", "단위": "㎥", "수량": Decimal("5")}],
|
||||
"L2": [{"이름": "돌", "단위": "㎥", "수량": 8}, {"이름": "경유", "단위": "ℓ", "수량": 3}],
|
||||
}
|
||||
(tmp_path / "단가.json").write_text(json.dumps(price, ensure_ascii=False), "utf-8")
|
||||
(tmp_path / "일위대가.json").write_text(
|
||||
json.dumps({"참조사본": frozen}, ensure_ascii=False), "utf-8"
|
||||
)
|
||||
monkeypatch.setattr(copies, "calc", lambda root, folder: fake)
|
||||
monkeypatch.setattr(copies, "folder_of", lambda root, folder: tmp_path)
|
||||
monkeypatch.setattr(item_prices, "load_prices", lambda bundle: "whole")
|
||||
monkeypatch.setattr(labor, "run_line", lambda whole, code, *rest: {"재료": mats[code]})
|
||||
meta: dict = {}
|
||||
got = router._folder_units("r", "f", meta)
|
||||
# 사전조합 1 단위 = 모래 5 ÷ 10 × 2 + 돌 2 · 승격 1 단위 = 돌 8 ÷ 4 · 로직 줄 = × 4 ÷ 2
|
||||
assert got == {"모래|㎥": 2.0, "돌|㎥": 4.0 + 6.0, "페인트|ℓ": 2.0, "MT9": 1.0}
|
||||
assert meta["MT9"] == {
|
||||
"key": "MT9",
|
||||
"name": "레디믹스트 레미콘",
|
||||
"spec": "25-18-80",
|
||||
"unit": "㎥",
|
||||
}
|
||||
assert matched("레디믹스트 레미콘", "25-18-80", "㎥") == "remicon_25_18_80"
|
||||
@@ -0,0 +1,47 @@
|
||||
"""160-4 B08 콘크리트(기계) · 모르터 1:3 열 = 사본 없어도 늘 보임 + 손 단위수량 → 콘크리트소요
|
||||
(엑셀 I66 · R66 꼴 · 레미콘은 안 거침) → 총괄 시멘트 · 모래 · 자갈 — 엑셀 2공구 검산.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute
|
||||
from resources.tester.test_121_b08_material_sheets import (
|
||||
EXCEL_MIX,
|
||||
EXCEL_PANEL,
|
||||
SUMMARY,
|
||||
UNITS,
|
||||
_run_ts,
|
||||
)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_두_열은_사본_없어도_늘_보임():
|
||||
bare = {"columns": SUMMARY["columns"], "total": SUMMARY["total"]}
|
||||
keys = [m["key"] for m in compute(bare, clean_overrides({}))["quantity"]["materials"]]
|
||||
assert keys == ["concrete_machine", "pipe_800", "pipe_1000", "pipe_1200", "mortar_1_3"]
|
||||
|
||||
|
||||
def test_엑셀_검산_시멘트_모래_자갈_레미콘():
|
||||
"""손 단위수량(엑셀 자재수량집계표) · 엑셀 배합 · 엑셀 할증 → 총괄 G18 244 · G4 16 · G5 19."""
|
||||
over = clean_overrides({"units": UNITS, "mix": EXCEL_MIX})
|
||||
sheets = compute(SUMMARY, over, EXCEL_PANEL)
|
||||
concrete = {row["key"]: row for row in sheets["concrete"]["rows"]}
|
||||
assert concrete["c40"]["qty"] == 27.73 and concrete["m13"]["qty"] == 1.162 # I66 · R66
|
||||
assert [concrete[k]["qty"] for k in ("c25", "c40_plain", "m12")] == [None] * 3 # 두 열에서만
|
||||
assert sheets["concrete"]["total"] == {"cement": 9549.41, "sand": 14.5886, "gravel": 18.0245}
|
||||
total = {row["key"]: row for row in sheets["material"]["rows"]}
|
||||
assert total["cement"]["amount"] == 244
|
||||
assert total["sand"]["amount"] == 16 and total["gravel"]["amount"] == 19
|
||||
assert total["remicon_25_18_80"]["amount"] == 31.62 # 레미콘 = 총괄로 곧장
|
||||
assert _run_ts(SUMMARY, over) == compute(SUMMARY, over)
|
||||
|
||||
|
||||
def test_엑셀_손값과_다른_상수는_알림만():
|
||||
ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text(encoding="utf-8")
|
||||
assert "2공구 엑셀 손값과 다름" in ui and "b08MaterialExcel" in ui
|
||||
# 기본 배합은 품셈 근거 그대로(124)
|
||||
per = {r["key"]: r["per"] for r in compute(SUMMARY, clean_overrides({}))["concrete"]["rows"]}
|
||||
assert per["c40"] == {"cement": 323, "sand": 0.456, "gravel": 0.612}
|
||||
@@ -0,0 +1,98 @@
|
||||
"""160-5 B08 채집석 공제 — 돌(채집) 둘 · 고임돌 · 막자갈 계 합(엑셀 Y68) → 총괄 줄 · 토공집계표
|
||||
잔토처리에서 뺌(엑셀 토적집계표 H32) · 잔토처리 끝수 = ROUND 0 그대로(브레인 정함) · 거울.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Material as router
|
||||
from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
|
||||
excel_round,
|
||||
haul_lists,
|
||||
summary_rows,
|
||||
)
|
||||
from B08_Quantity.B08_Quantity_Material_Calc import clean_overrides, compute
|
||||
from resources.tester.test_121_b08_material_sheets import SUMMARY, UNITS, _run_ts
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BALANCE = 994.4747340425532 # 엑셀 사토운반본선!K19
|
||||
TOTALS = {
|
||||
"cut_soil_volume_m3": 0.0,
|
||||
"cut_rock_volume_m3": 0.0,
|
||||
"ditch_soil_volume_m3": 0.0,
|
||||
"ditch_rock_volume_m3": 0.0,
|
||||
"adjusted_total_m3": 0.0,
|
||||
"fill_volume_m3": 0.0,
|
||||
"diverted_m3": 0.0,
|
||||
"balance_m3": BALANCE,
|
||||
"benching_area_m2": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _deduct_row(sheets):
|
||||
return next(row for row in sheets["material"]["rows"] if row["key"] == "picked_deduct")
|
||||
|
||||
|
||||
def test_총괄_채집석_공제_엑셀_Y68():
|
||||
over = clean_overrides({"units": UNITS})
|
||||
sheets = compute(SUMMARY, over)
|
||||
row = _deduct_row(sheets)
|
||||
# 32.85 + 104.4 + 52.47 + 84.94 = 274.66 · 끝수 없음 · 할증 없음
|
||||
assert (row["group"], row["unit"], row["qty"], row["amount"]) == (
|
||||
"채집석 공제",
|
||||
"㎥",
|
||||
274.66,
|
||||
274.66,
|
||||
)
|
||||
assert row["surcharge"] is None
|
||||
keys = {r["key"] for r in sheets["material"]["rows"] if r["qty"] is not None}
|
||||
assert not {"stone_pick_35", "stone_pick_45", "bed_stone"} & keys # 채집재는 총괄로 안 감
|
||||
assert _run_ts(SUMMARY, over) == sheets
|
||||
assert _deduct_row(compute(SUMMARY, clean_overrides({})))["qty"] is None # 채집재 없으면 빈칸
|
||||
|
||||
|
||||
def test_잔토처리에서_뺌_엑셀_사토():
|
||||
rows = summary_rows(TOTALS, haul_lists(None), 90, 274.66)
|
||||
value = {r["group"]: r["value"] for r in rows}
|
||||
assert excel_round(BALANCE - 274.66, 2) == 719.81 # 엑셀 토적집계표 F32
|
||||
assert value["잔토처리"] == 720 # ROUND 0 그대로
|
||||
assert value["채집석 공제"] == 274.66
|
||||
assert {r["group"]: r["value"] for r in summary_rows(TOTALS, haul_lists(None))}[
|
||||
"잔토처리"
|
||||
] == 994
|
||||
|
||||
|
||||
def test_거울_TS_잔토처리():
|
||||
rows = summary_rows(TOTALS, haul_lists(None), 90, 274.66)
|
||||
calc = (ROOT / "B08_Quantity" / "B08_Quantity_Options_Calc.ts").read_text(encoding="utf-8")
|
||||
script = calc + (
|
||||
f"\nconst rows = {json.dumps(rows, ensure_ascii=False)};"
|
||||
f"\nconsole.log(JSON.stringify(recountAdjustedSummary(rows, {json.dumps(TOTALS)}, 0)));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == json.loads(json.dumps(rows))
|
||||
|
||||
|
||||
def test_서버_공제는_저장본(tmp_path, monkeypatch):
|
||||
async def summary(_project_id):
|
||||
return {**SUMMARY, "rows": [], "notes": []}
|
||||
|
||||
async def project_root(_project_id):
|
||||
return tmp_path
|
||||
|
||||
monkeypatch.setattr(router, "structure_summary", summary)
|
||||
monkeypatch.setattr(router, "_project_root", project_root)
|
||||
assert asyncio.run(router.picked_deduction("p", tmp_path)) is None
|
||||
asyncio.run(router.save_material_sheets("p", {"overrides": {"units": UNITS}}))
|
||||
assert asyncio.run(router.picked_deduction("p", tmp_path)) == 274.66
|
||||
earth = (ROOT / "B08_Quantity" / "B08_Quantity_Router_Earthwork.py").read_text("utf-8")
|
||||
assert 'summary_rows(table["totals"], haul, soft, deduct)' in earth
|
||||
@@ -202,8 +202,9 @@ def test_토공집계표_식() -> None:
|
||||
assert notes[group] == "비율 미정"
|
||||
assert value[("표토제거", "", "")] is None
|
||||
assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows}
|
||||
assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7
|
||||
assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8
|
||||
# + 채집석 공제(160-5 · 공제 없으면 빈칸)
|
||||
assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 8
|
||||
assert len(rows) == 26 # 시트 C5:C37 33 − 구조물 쪽 8 + 채집석 공제
|
||||
|
||||
|
||||
def test_토적표_머리_글자는_사용자_견본대로():
|
||||
|
||||
Reference in New Issue
Block a user