Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-09 22:54:05 +09:00
13 changed files with 542 additions and 51 deletions
@@ -15,6 +15,8 @@ Q · L 출처 = 저장된 유토곡선 배분(`mass_haul.haul_plan` — B07 토
풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트 풍화암 · 보통암 · 표토제거 · 법면보호공 · 운반성토 줄은 값이 없어 비움(None) · 구조물 쪽 줄(su2 시트
구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 · 구조물터파기 · 토사다짐 · 되메우기 · 잔토)은 뺌 · 거창 계수(성토면다짐 0.8 · 초류종자 성토 0.5 ·
지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2). 지장목 0.8)는 근거가 없어 값 빈칸 + 「비율 미정」(PLAN 143-2).
채집석 공제(160-5) = 자재 총괄 「채집석 공제」 값(돌(채집) 둘 · 고임돌 · 막자갈 · 2공구 엑셀
토적집계표 H32 = −자재수량집계표!Y68) → 잔토처리 = ROUND(잔토 − 공제) · 공제 줄은 잔토처리 아래.
짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` · 짝 — `rock_split` · `split_rock_columns` ↔ `B08_Quantity_Options_Calc.ts` `rockSplit` ·
`splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`). `splitRockColumns` · `splitRockSummary`(거울 시험 `test_122_b08_options_mirror`).
@@ -93,8 +95,10 @@ def summary_rows(
totals: dict[str, float], totals: dict[str, float],
haul: dict[str, dict[str, Any]], haul: dict[str, dict[str, Any]],
soft_pct: float = SOFT_ROCK_PCT_DEFAULT, soft_pct: float = SOFT_ROCK_PCT_DEFAULT,
deduct: float | None = None,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔.""" """토공집계표 줄 — 시트 C5:G37 차례 · 식 그대로(셀 주소는 줄 끝 주석) · 암 = 연암 % 로 나눔 ·
`deduct` = 채집석 공제 ㎥(160-5 · 없으면 빈칸)."""
t = totals t = totals
cut_soft, cut_hard = rock_split(t["cut_rock_volume_m3"], soft_pct) 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) 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("종방향유용토", "", "백호우", m3, free_q, "유용토"), # 종방향무대운반거리!B25
_row("토사운반", "", "BH+DT+BH", m3, dump_q), # 평균운반거리!B42 _row("토사운반", "", "BH+DT+BH", m3, dump_q), # 평균운반거리!B42
_row("토사운반", "", "19 TON B/D", m3, dozer_q), # 평균운반거리 불도우저 ΣQ _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("층따기", "", "백호우", m3, t["benching_area_m2"]), # 토적표!W137
_row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8 _row("성토면다짐", "", "", "㎡", None, unsure), # 거창 = 성토면 × 0.8
_row("표토제거", "", "", "㎡", None), _row("표토제거", "", "", "㎡", None),
+39 -12
View File
@@ -15,9 +15,12 @@
단위수량 기본값(143-4) = 연결된 사본 일위대가 자재 · 로직 재료(`summary.snapshot` · 서버가 단위수량 기본값(143-4) = 연결된 사본 일위대가 자재 · 로직 재료(`summary.snapshot` · 서버가
사본 셈으로 냄 — `B08_Quantity_Router_Material.snapshot_units`) · 자재 열 = 그 사본 자재 사본 셈으로 냄 — `B08_Quantity_Router_Material.snapshot_units`) · 자재 열 = 그 사본 자재
(키 = MT 코드 · 로직 재료 = 이름|단위) + 관경 · 2공구 고정 열(`MATERIALS`)은 사용자 값이 있을 (키 = MT 코드 · 로직 재료 = 이름|단위) + 관경 · 2공구 고정 열(`MATERIALS`)은 사용자 값이 있을
때만 · 콘크리트소요 · 때만(콘크리트(기계) · 모르터 1:3 = 콘크리트소요 원천이라 늘 · 160-4) · 콘크리트소요 ·
총괄로 끌어옴 = 이름 · 규격 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched`(화면 알림) · 총괄로 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 안 맞는 사본 자재 = `unmatched` → 총괄 끝에
그 이름 · 규격 · 단위 그대로 줄(할증 빈칸 · 끝수 없음 · 160-3) ·
사본 없거나 그 자재가 없으면 빈칸 · 관매설 → 파형강관 1 은 그대로 · 손 값이 이김. 사본 없거나 그 자재가 없으면 빈칸 · 관매설 → 파형강관 1 은 그대로 · 손 값이 이김.
채집석 공제(160-5) = 돌(채집) 둘 · 고임돌 · 막자갈 계 합(엑셀 Y68 · 끝수 없음) → 총괄 줄 · 토공
잔토처리에서 뺌(`B08_Quantity_Engine_Earthwork_Summary.summary_rows`).
사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만: 사용자 값(`overrides`) = 프로젝트 설정 quantity › material_sheets — 고친 칸만:
{"units": {줄 id: {자재: 값}}, "mix": {배합 줄: {cement|sand|gravel: 값}}, {"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}), ("m13", "모르터", "1:3", "mortar_1_3", {"cement": 510, "sand": 1.10, "gravel": None}),
] ]
MIX_FIELDS = ("cement", "sand", "gravel") 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}"(콘크리트소요 계) # 끌어올 곳 = "m:{자재}"(자재수량집계표 계) · "c:{cement|sand|gravel}"(콘크리트소요 계)
@@ -201,7 +209,7 @@ def material_columns(
for entry in MATERIALS: for entry in MATERIALS:
if entry[0] == "PIPES": if entry[0] == "PIPES":
out += [(f"pipe_{d}", "파형강관", f"Φ{d}", "m") for d in sizes] 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) out.append(entry)
have = {m[0] for m in out} have = {m[0] for m in out}
for m in extra or []: for m in extra or []:
@@ -214,13 +222,23 @@ def _norm(text: str) -> str:
return re.sub(r"\s+", "", str(text or "")).lower() 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공구 고정 열 키(콘크리트소요 · 총괄 끌어옴) — 이름에 고정 이름 · 규격이 다 """사본 자재 → 2공구 고정 열 키(콘크리트소요 · 총괄 끌어옴) — 이름에 고정 이름 · 규격이 다
듦 · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이 실무 이름과 다르면 「맞춤 없음」(맞춤 표는 듦 + 단위 같음(kg ↔ ton 은 맞춤 아님 · 160-3) · 첫 맞춤. ponytail: 글 맞춤 — 품목 이름이
필요할 때).""" 실무 이름과 다르면 「맞춤 없음」(총괄 끝 줄 · 맞춤 표는 146-3)."""
text = _norm(f"{name}{spec}") text = _norm(f"{name}{spec}")
for key, fixed, fixed_spec, _ in MATERIALS: for key, fixed, fixed_spec, fixed_unit in MATERIALS:
if key != "PIPES" and _norm(fixed) in text and _norm(fixed_spec) in text: if (
key != "PIPES"
and _norm(fixed) in text
and _norm(fixed_spec) in text
and _unit(unit) == _unit(fixed_unit)
):
return key return key
return None return None
@@ -286,10 +304,10 @@ def compute(
fixed = {m[0] for m in MATERIALS} | {f"pipe_{d}" for d in sizes} fixed = {m[0] for m in MATERIALS} | {f"pipe_{d}" for d in sizes}
pulled: dict[str, float] = {} pulled: dict[str, float] = {}
unmatched: list[str] = [] unmatched: list[str] = []
for key, name, spec, _ in materials: for key, name, spec, unit in materials:
if key not in sums: if key not in sums:
continue 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: if target is None:
unmatched.append(key) unmatched.append(key)
else: else:
@@ -337,20 +355,29 @@ def compute(
] ]
else: else:
plan.append(entry) 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 = [] total_rows = []
for key, group, spec, unit, source, pct_default, rule in plan: for key, group, spec, unit, source, pct_default, rule in plan:
ref, _, divisor = source.partition("/") ref, _, divisor = source.partition("/")
kind, _, name = ref.partition(":") kind, _, name = ref.partition(":")
qty: float | None = None qty: float | None = None
if kind == "m": 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": elif kind == "c":
qty = concrete_total.get(name) qty = concrete_total.get(name)
elif kind == "cp": elif kind == "cp":
qty = couplings.get(int(name)) qty = couplings.get(int(name))
if qty is not None and divisor: if qty is not None and divisor:
qty = round6(qty / float(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 amount = finish(qty * (1 + (pct or 0) / 100), rule) if qty is not None else None
total_rows.append( total_rows.append(
{ {
+43 -16
View File
@@ -12,7 +12,9 @@
* 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리 * 사용자 값 = 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본값) — 상수 출처는 파이썬 짝 머리
* 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬) * 좌측 패널 할증(`panel`) = 총괄 모래 · 자갈 · 시멘트 줄(표 사용자 값보다 앞섬)
* 단위수량 기본값(143-4) = 서버가 낸 사본 자재(`summary.snapshot`) · 자재 열 = 사본 자재 + 관경 · * 단위수량 기본값(143-4) = 서버가 낸 사본 자재(`summary.snapshot`) · 자재 열 = 사본 자재 + 관경 ·
* 2공구 고정 열은 사용자 값 있을 때만 · 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 맞춤(`matched`) * 2공구 고정 열은 사용자 값 있을 때만(콘크리트(기계) · 모르터 1:3 은 늘 · 160-4) ·
* 콘크리트소요 · 총괄 끌어옴 = 이름 · 규격 · 단위 맞춤(`matched`) · 맞춤 없음 = 총괄 끝 줄(160-3)
* 채집석 공제(160-5) = 채집재 넷 계 합 → 총괄 줄 · 토공 잔토처리에서 뺌
* ========================================================================== */ * ========================================================================== */
export type Cells = Record<string, number | null>; export type Cells = Record<string, number | null>;
@@ -77,7 +79,7 @@ export interface MaterialSheets {
materials: MaterialColumn[]; materials: MaterialColumn[];
rows: QuantityRow[]; rows: QuantityRow[];
total: Record<string, number>; total: Record<string, number>;
/** 2공구 고정 열에 안 맞아 콘크리트소요 · 총괄로 못 끌어온 사본 자재 키(143-4) */ /** 2공구 고정 열에 안 맞은 사본 자재 키(143-4) — 총괄 끝에 그대로 줄(160-3) */
unmatched: string[]; unmatched: string[];
}; };
concrete: { rows: ConcreteRow[]; total: Record<string, number> }; 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 }], ["m13", "모르터", "1:3", "mortar_1_3", { cement: 510, sand: 1.1, gravel: null }],
]; ];
export const MIX_FIELDS = ["cement", "sand", "gravel"] as const; 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 */ /** 좌측 패널 할증(PLAN 124) — 총괄 모래 · 자갈 · 시멘트 줄 · 기본 = 1-3-1 현장비빔(기타) 12 · 5 · 3 */
export const PANEL_SURCHARGE: Record<string, number> = { sand: 12, gravel: 5, cement: 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[] => const out = MATERIALS.flatMap((entry): Entry[] =>
entry[0] === "PIPES" entry[0] === "PIPES"
? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"]) ? sizes.map((d): Entry => [`pipe_${d}`, "파형강관", `Φ${d}`, "m"])
: shown.has(entry[0]) : shown.has(entry[0]) || ALWAYS.includes(entry[0])
? [entry] ? [entry]
: [], : [],
); );
@@ -230,11 +237,20 @@ const norm = (text: string): string =>
.replace(/\s+/g, "") .replace(/\s+/g, "")
.toLowerCase(); .toLowerCase();
/** 사본 자재 → 2공구 고정 열 키 — 이름에 고정 이름 · 규격이 다 듦 · 첫 맞춤(파이썬 짝 `matched`) */ /** 단위 맞춤 글 — 빈칸 · 대소문 무시 · m3 = ㎥ · m2 = ㎡(파이썬 짝 `_unit`) */
export function matched(name: string, spec: string): string | null { 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}`); const text = norm(`${name}${spec}`);
for (const [key, fixed, fixedSpec] of MATERIALS) for (const [key, fixed, fixedSpec, fixedUnit] of MATERIALS)
if (key !== "PIPES" && text.includes(norm(fixed)) && text.includes(norm(fixedSpec))) return key; if (
key !== "PIPES" &&
text.includes(norm(fixed)) &&
text.includes(norm(fixedSpec)) &&
unitOf(unit) === unitOf(fixedUnit)
)
return key;
return null; return null;
} }
@@ -291,9 +307,9 @@ export function computeMaterialSheets(
const fixed = new Set([...MATERIALS.map((m) => m[0]), ...sizes.map((d) => `pipe_${d}`)]); const fixed = new Set([...MATERIALS.map((m) => m[0]), ...sizes.map((d) => `pipe_${d}`)]);
const pulled: Record<string, number> = {}; const pulled: Record<string, number> = {};
const unmatched: string[] = []; const unmatched: string[] = [];
for (const [key, name, spec] of materials) { for (const [key, name, spec, unit] of materials) {
if (!(key in sums)) continue; 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); if (target === null) unmatched.push(key);
else add(pulled, target, sums[key]); else add(pulled, target, sums[key]);
} }
@@ -350,19 +366,30 @@ export function computeMaterialSheets(
] ]
: [entry], : [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 totalRows: TotalRow[] = plan.map(([key, group, spec, unit, source, pctDefault, rule]) => {
const [ref, divisor = ""] = source.split("/"); const [ref, divisor = ""] = source.split("/");
const [kind, name = ""] = ref.split(":"); const [kind, name = ""] = ref.split(":");
let qty = let qty =
kind === "m" kind === "m"
? (pulled[name] ?? null) ? (pulled[name] ?? (unmatched.includes(name) ? sums[name] : null))
: kind === "c" : kind === "picked"
? (concreteTotal[name] ?? null) ? picked.length
: kind === "cp" ? round6(picked.reduce((a, b) => a + b, 0))
? (couplings.get(Number(name)) ?? null) : null
: null; : kind === "c"
? (concreteTotal[name] ?? null)
: kind === "cp"
? (couplings.get(Number(name)) ?? null)
: null;
if (qty !== null && divisor) qty = round6(qty / Number(divisor)); 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; const amount = qty !== null ? finish(qty * (1 + (pct ?? 0) / 100), rule) : null;
return { return {
key, key,
+4 -2
View File
@@ -160,18 +160,20 @@ export function recountRockC(
/** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */ /** 엑셀 ROUND(정수) — 반은 0 에서 먼 쪽. */
const excelRound = (value: number): number => Math.sign(value) * Math.round(Math.abs(value)); 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 }>( export function recountAdjustedSummary<R extends { group: string; value: number | null }>(
rows: readonly R[], rows: readonly R[],
totals: Record<string, number>, totals: Record<string, number>,
freeQ: number, freeQ: number,
): R[] { ): R[] {
const diverted = totals.diverted_m3 ?? 0; const diverted = totals.diverted_m3 ?? 0;
const deduct = rows.find((row) => row.group === "채집석 공제")?.value ?? 0;
const values: Record<string, number> = { const values: Record<string, number> = {
보정량계: excelRound(totals.adjusted_total_m3 ?? 0), 보정량계: excelRound(totals.adjusted_total_m3 ?? 0),
"유용토 소계": diverted + freeQ, "유용토 소계": diverted + freeQ,
횡방향유용토: diverted, 횡방향유용토: 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)); return rows.map((row) => (row.group in values ? { ...row, value: values[row.group] } : row));
} }
@@ -9,6 +9,7 @@
토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)` · 암 = 연암 · 경암 비율 가중 C · 토량환산계수 = 유토곡선과 같은 `mixed_conversion_factors(quantity_settings)` · 암 = 연암 · 경암 비율 가중 C ·
`rock_c` = 가중 전 리핑암 · 발파암 C(조작 중 브라우저가 다시 가중 · 125-1). `rock_c` = 가중 전 리핑암 · 발파암 C(조작 중 브라우저가 다시 가중 · 125-1).
암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표. 암 → 연암 · 경암 = 암 × 연암 %(`rock_ratios_pct` · 기본 90 · PLAN 122) — 토적표 줄 · 합계 · 토공집계표.
잔토처리 = 잔토 − 채집석 공제(자재 총괄 저장본 · `picked_deduction` · 160-5).
""" """
from __future__ import annotations from __future__ import annotations
@@ -32,6 +33,7 @@ from B08_Quantity.B08_Quantity_Engine_Earthwork_Summary import (
summary_rows, summary_rows,
) )
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root 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 ( from common_util.common_util_project_settings import (
earthwork_conversion_factors, earthwork_conversion_factors,
mixed_conversion_factors, mixed_conversion_factors,
@@ -63,9 +65,10 @@ async def earthwork_table(project_id: UUID) -> dict[str, Any]:
split_rock_columns(record, soft) split_rock_columns(record, soft)
mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {} mass_haul = ((section or {}).get("data") or {}).get("mass_haul") or {}
haul = haul_lists(mass_haul.get("haul_plan")) haul = haul_lists(mass_haul.get("haul_plan"))
deduct = await picked_deduction(project_id, root)
return { return {
**table, **table,
"summary": summary_rows(table["totals"], haul, soft), "summary": summary_rows(table["totals"], haul, soft, deduct),
"rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft}, "rock_ratios_pct": {"연암": soft, "경암": 100.0 - soft},
"rock_c": { "rock_c": {
kind: entry["compacted"] kind: entry["compacted"]
+103 -10
View File
@@ -9,12 +9,15 @@
좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options) 좌측 패널 값(`surcharge_pct` · 저장은 옵션 길 PUT …/quantity/options)
기본값은 파일에 안 씀(읽을 때 채움). 기본값은 파일에 안 씀(읽을 때 채움).
단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 · 단위수량 기본(143-4) = 연결된 사본 셈(`M02_Item_Copy.calc` = 층 셈 한 벌)의 일위대가 자재 ·
로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산). 로직 재료(`snapshot_units` · 서버 단독 — 브라우저는 받은 값으로 짝 계산) · 참조 줄(승격 · 사전조합)
안 재료도 셈 · 기계 주연료 · 할증(ℓ)은 뺌(원가 몫 · 160-3).
""" """
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import re
from decimal import Decimal
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from uuid import UUID from uuid import UUID
@@ -22,6 +25,7 @@ from uuid import UUID
from fastapi import APIRouter, Body from fastapi import APIRouter, Body
from B08_Quantity.B08_Quantity_Material_Calc import ( from B08_Quantity.B08_Quantity_Material_Calc import (
DEDUCT_KEY,
PANEL_KEY, PANEL_KEY,
_num, _num,
clean_overrides, clean_overrides,
@@ -37,19 +41,75 @@ from common_util.common_util_summary_item import GROUP_JOIN
router = APIRouter(prefix="/api/projects", tags=["B08 Material"]) router = APIRouter(prefix="/api/projects", tags=["B08 Material"])
SETTINGS_KEY = "material_sheets" 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]: def _folder_units(root: str | Path, folder: str, meta: dict[str, dict]) -> dict[str, float]:
"""사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료 """사본 하나 = 집계 항목 1 단위의 자재 `{키: 수량}` — 일위대가 자재(키 = MT 코드) + 로직 재료
(키 = 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) · 줄 수량 빈칸 · 0 · 파형강관은 안 담음 · 셈 못 하면 (키 = 고른 품목 MT 코드 · 없으면 이름|단위 · 재료 수량 × 줄 수량 ÷ 배) + 참조 줄 안 재료
빈 것. ponytail: 참조 줄(승격 · (160-3) · 줄 수량 빈칸 · 0 · 파형강관 · 기계 연료는 안 담음 · 셈 못 하면 빈 것."""
사전조합) 안 재료는 안 셈 — 펼친 재료가 셈 답에 오면 그때."""
from M02_MasterTemplete import M02_Item_Copy as copies 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: try:
got = copies.calc(root, folder) got = copies.calc(root, folder)
bundle = read_json(copies.folder_of(root, folder) / PRICE_FILE) or {} 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): except (LookupError, ValueError):
return {} return {}
rows = { 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}) meta.setdefault(key, {"key": key, "name": name, "spec": spec, "unit": unit})
out[key] = out.get(key, 0.0) + qty 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 []: for line in got.get("줄") or []:
qty, times = _num(line.get("수량")), _num(line.get("배")) or 1.0 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: if (per := _num(mat.get("수량"))) is not None:
name, unit = str(mat.get("이름") or ""), str(mat.get("단위") or "") logic_mat(mat, per * qty / times)
put(f"{name}|{unit}", name, "", unit, per * qty / times)
for mat in got.get("자재") or []: for mat in got.get("자재") or []:
code, qty = str(mat.get("코드") or ""), _num(mat.get("수량")) code, qty = str(mat.get("코드") or ""), _num(mat.get("수량"))
if code and qty is not None: 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) sheets = compute(source, overrides, panel)
notes = list(summary.get("notes") or []) notes = list(summary.get("notes") or [])
if lost := sheets["quantity"]["unmatched"]: if lost := sheets["quantity"]["unmatched"]:
notes.append( names = {m["key"]: m for m in sheets["quantity"]["materials"]}
f"사본 자재 {len(lost)}종 — 2공구 열 맞춤 없음(콘크리트소요 · 총괄로 안 끌어옴)" 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 { return {
"source": source, "source": source,
"overrides": overrides, "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") @router.get("/{project_id}/quantity/materials")
async def material_sheets(project_id: UUID) -> dict[str, Any]: async def material_sheets(project_id: UUID) -> dict[str, Any]:
root = await _project_root(project_id) root = await _project_root(project_id)
+48 -4
View File
@@ -8,6 +8,8 @@
* 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈) * 총괄 모래 · 자갈 · 시멘트 할증 = 좌측 패널 값(PLAN 124 · 표에서 안 고침 · 패널 조작에 바로 다시 셈)
* [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음 * [저장] = PUT …/quantity/materials → 서버가 정본으로 다시 셈 · 캐시 비움 · 자동 저장 없음
* 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3) * 값 없는 줄은 숨김(화면만 · 셈 · 열 · 단위수량은 그대로) · 표는 공용 카드 안(PLAN 143-3)
* 콘크리트(기계) · 모르터 1:3 열 = 늘 보임 · 손 단위수량 → 콘크리트소요(160-4) · 엑셀 손값과 다른
* 배합 · 할증은 표 아래 알림 한 줄만 · 맞춤 없는 사본 자재 · 채집석 공제 = 총괄 끝 줄(160-3 · 5)
* ========================================================================== */ * ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend"; import { API_BASE_URL } from "@config/config_frontend";
@@ -23,6 +25,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { import {
blankUnitRows, blankUnitRows,
computeMaterialSheets, computeMaterialSheets,
DEDUCT_KEY,
MIX_FIELDS, MIX_FIELDS,
type Cells, type Cells,
type MaterialOverrides, type MaterialOverrides,
@@ -49,7 +52,8 @@ const HINTS: Record<MaterialSheetId, string> = {
material: material:
"합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널", "합계 = 끝수(수량 × (1 + 할증)) — 레미콘 ROUND 2 · 철근 · 와이어메쉬 ROUND 3 · 시멘트 ROUND 0 · 그 밖 올림 · 할증 % 기본 = 건설품셈 1-3-1 · 관급 파형강관 0 — 고칠 수 있음 · 모래 · 자갈 · 시멘트는 좌측 패널",
concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음", concrete: "㎥당 = 산림품셈 12-1-3 (B) 배합 · 건축 9-1-1 모르터 기본(무근은 빈칸) — 고칠 수 있음",
quantity: "수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1", quantity:
"수량 = 구조물 수량 × 단위수량 · 단위수량은 빈칸(사용자 입력) · 관매설은 1 · 콘크리트(기계) · 모르터 1:3 단위수량 → 콘크리트소요",
}; };
const HIDDEN = "값 없는 줄은 숨김"; const HIDDEN = "값 없는 줄은 숨김";
@@ -157,6 +161,34 @@ const MIX_LABEL: Record<(typeof MIX_FIELDS)[number], [string, string, string]> =
gravel: ["자갈", "㎥당(㎥)", "수량(㎥)"], 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 { function concreteTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows, total } = s.concrete; const { rows, total } = s.concrete;
const keep = shown(rows, (row) => row.qty != null || anyValue(row.amounts)); 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 { function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
const { rows } = s.material; const { rows } = s.material;
const lost = new Set(s.quantity.unmatched);
const keep = shown(rows, (row) => row.qty != null || row.amount != null); const keep = shown(rows, (row) => row.qty != null || row.amount != null);
return { return {
columns: [ columns: [
@@ -225,12 +258,19 @@ function totalTable(s: MaterialSheets, o: MaterialOverrides): Table {
qty: row.qty, qty: row.qty,
surcharge: row.surcharge, surcharge: row.surcharge,
amount: row.amount, amount: row.amount,
note: row.panel ? "할증 = 좌측 패널" : "", note: row.panel
? "할증 = 좌측 패널"
: row.key === DEDUCT_KEY
? "토공 잔토처리에서 뺌"
: lost.has(row.key)
? "2공구 맞춤 없음"
: "",
}; };
}), }),
total: null, total: null,
frozen: 2, 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], target: (i) => [o.surcharge, rows[keep[i]].key, null],
}; };
} }
@@ -312,11 +352,15 @@ export async function renderMaterialSheet(
}, },
}); });
table.root.dataset.b08Material = sheet; table.root.dataset.b08Material = sheet;
const excel = note("");
excel.dataset.b08MaterialExcel = "";
const redraw = (): void => { const redraw = (): void => {
built = BUILD[sheet](sheets, overrides); built = BUILD[sheet](sheets, overrides);
table.setRows(built.rows, built.total); 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, () => { onOptionsChange(host, () => {
sheets = calc(); sheets = calc();
redraw(); redraw();
@@ -26,5 +26,5 @@ def test_자재_셋_값_없는_줄_숨김():
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 assert "createCard({ body: parts })" in SUMMARY
@@ -53,7 +53,16 @@ def test_사본_자재가_열과_단위수량_기본():
over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}}) over = clean_overrides({"units": {"pipe:chute|메붙임": {"고임돌|㎥": 0.5, "sod": 1}}})
sheets = compute(SUMMARY, over) sheets = compute(SUMMARY, over)
keys = [m["key"] for m in sheets["quantity"]["materials"]] 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"]} rows = {row["id"]: row for row in sheets["quantity"]["rows"]}
revet = rows["pipe:revet|돌쌓기(찰)|2.0"] revet = rows["pipe:revet|돌쌓기(찰)|2.0"]
assert revet["units"]["MT000001"] == 0.31 and revet["amounts"]["MT000001"] == 3.1 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 그대로 assert rows[LEN]["units"]["pipe_1000"] == 1 # 관매설 → 파형강관 1 그대로
# 끌어옴 — 레미콘 25-18-80 · 고임돌(채집) = 이름 · 규격 맞춤 · 야면석은 맞춤 없음 # 끌어옴 — 레미콘 25-18-80 · 고임돌(채집) = 이름 · 규격 맞춤 · 야면석은 맞춤 없음
assert sheets["quantity"]["unmatched"] == ["야면석|㎥"] 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"]} total = {row["key"]: row for row in sheets["material"]["rows"]}
assert total["remicon_25_18_80"]["qty"] == 3.1 assert total["remicon_25_18_80"]["qty"] == 3.1
assert total["pipe_1000"]["qty"] == 20 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 notes[group] == "비율 미정"
assert value[("표토제거", "", "")] is None assert value[("표토제거", "", "")] is None
assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows} assert not {"구조물터파기", "토사다짐", "되메우기", "잔토"} & {r["group"] for r in rows}
assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 7 # + 채집석 공제(160-5 · 공제 없으면 빈칸)
assert len(rows) == 25 # 시트 C5:C37 33 − 구조물 쪽 8 assert sum(r["value"] is None and r["note"] != "비율 미정" for r in rows) == 8
assert len(rows) == 26 # 시트 C5:C37 33 − 구조물 쪽 8 + 채집석 공제
def test_토적표_머리_글자는_사용자_견본대로(): def test_토적표_머리_글자는_사용자_견본대로():