Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -179,7 +179,12 @@ export interface MilestoneBase {
|
||||
}
|
||||
|
||||
export interface MilestoneData {
|
||||
site: { mid_station_m: number | null; mid_road: Road };
|
||||
site: {
|
||||
mid_station_m: number | null;
|
||||
mid_road: Road;
|
||||
/** 길찾기용 시점 덮개(255-2 · 경로 탭 시점 끌기 · 노선은 그대로) — 없음 = 노선 첫 점 */
|
||||
start_pin?: { lon: number; lat: number } | null;
|
||||
};
|
||||
bases: MilestoneBase[];
|
||||
routes: MilestoneRoute[];
|
||||
}
|
||||
|
||||
@@ -133,9 +133,15 @@ class Base(Place):
|
||||
linked: bool = True # False = 아직 선 안 이음(요소 추가 뒤)
|
||||
|
||||
|
||||
class LonLat(BaseModel):
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
|
||||
|
||||
class Site(BaseModel):
|
||||
mid_station_m: float | None = Field(default=None, ge=0, le=1_000_000)
|
||||
mid_road: Road = "비포장"
|
||||
start_pin: LonLat | None = None # 255-2 길찾기용 시점 덮개(노선은 그대로)
|
||||
|
||||
|
||||
class Milestone(BaseModel):
|
||||
@@ -497,11 +503,6 @@ KAKAO_URL = "https://apis-navi.kakaomobility.com/v1/directions"
|
||||
KAKAO_KEY = "KAKAO_REST_API_KEY"
|
||||
|
||||
|
||||
class LonLat(BaseModel):
|
||||
lon: float = Field(ge=-180, le=180)
|
||||
lat: float = Field(ge=-90, le=90)
|
||||
|
||||
|
||||
class Directions(BaseModel):
|
||||
origin: LonLat
|
||||
destination: LonLat
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
roadOptions,
|
||||
segmentPath,
|
||||
siteInfo,
|
||||
startPoint,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
const km = (text: string): number | null => {
|
||||
@@ -147,7 +148,7 @@ function openMidLeg(projectId: string): void {
|
||||
/** 연결선 누름 — from = 거래처 · 기준점 id(→ 기준점 · 시점 · 254) · "start"(시점 → 중점) */
|
||||
export function openLegInfo(projectId: string, from: string): void {
|
||||
if (from === "start") return openMidLeg(projectId);
|
||||
const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null;
|
||||
const start = (): [number, number] | null => startPoint(liveMilestone(projectId)); // 255-2 덮개
|
||||
const seg = segmentEnds(liveMilestone(projectId), from, start());
|
||||
if (!seg) return;
|
||||
openModal({
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
*
|
||||
* [그림 변경] = 사용자 그림 올림(이 탭 = 그 그림) · [초기화] = 사용자 그림만 지움(길찾기 지도로 · 즉시 서버)
|
||||
* 서버 자동 그림(244-11 · 선 이을 때 · 서류용)은 그대로 만듦 · 이 탭에는 안 보임(157)
|
||||
* 255-2 시점도 끌기 = 길찾기용 덮개(`site.start_pin`) · 표지 옮기면 그 자리에 닿는 구간 모두 다시 길찾기 ·
|
||||
* 열 때 선 없는 사슬 구간 모두 길찾기 · 실패 = 안내 한 줄 · 끌기는 그림 캡처 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
@@ -26,7 +28,8 @@ import {
|
||||
putMilestone,
|
||||
routeImageKey,
|
||||
segmentPath,
|
||||
siteInfo,
|
||||
startPoint,
|
||||
touchingSegments,
|
||||
uploadImage,
|
||||
} from "./B08_Quantity_UI_Milestone_State";
|
||||
|
||||
@@ -120,20 +123,19 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
});
|
||||
reset.dataset.b08MilestoneRevert = "";
|
||||
|
||||
const start = (): [number, number] | null => siteInfo()?.start_lonlat ?? null;
|
||||
const pins = (): MapPin[] => {
|
||||
const d = liveMilestone(projectId);
|
||||
const start = startPoint(d);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
const out: MapPin[] = [];
|
||||
const add = (key: string, label: string, lon?: number | null, lat?: number | null): void => {
|
||||
if (lon != null && lat != null)
|
||||
out.push({ key, name: label, lon, lat, movable: key !== "start" });
|
||||
if (lon != null && lat != null) out.push({ key, name: label, lon, lat, movable: true }); // 255-2 시점도 끌기(길찾기 덮개)
|
||||
};
|
||||
if (!r) return out;
|
||||
add(r.id, r.name || "거래처", r.lon, r.lat);
|
||||
if (r.linked === false) return out;
|
||||
for (const b of chainOf(d, r.to)) add(b.id, b.name || "기준점", b.lon, b.lat); // 254 기준점 사슬
|
||||
add("start", "시점", start()?.[0], start()?.[1]);
|
||||
add("start", "시점", start?.[0], start?.[1]);
|
||||
return out;
|
||||
};
|
||||
const lines = (): Array<Array<[number, number]>> => {
|
||||
@@ -142,24 +144,45 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
||||
return [routeId, ...chain].map(segmentPath).filter((p) => p.length >= 2);
|
||||
};
|
||||
// 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」)
|
||||
const status = el("p", { className: "b08-options__hint" });
|
||||
status.dataset.b08MilestoneRouteStatus = "";
|
||||
status.hidden = true;
|
||||
/** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */
|
||||
const refind = async (ids: string[]): Promise<void> => {
|
||||
const texts = await Promise.all(ids.map((id) => findSegmentKm(projectId, id, false)));
|
||||
const bad = texts.filter((t) => t.includes(" — "));
|
||||
status.textContent = bad.join(" · ");
|
||||
status.hidden = !bad.length;
|
||||
drawMap();
|
||||
};
|
||||
/** 표지 끌기 — 시점 = 덮개(site.start_pin · 노선 그대로) · 거래처 · 기준점 = 소재지 · 닿는 선 모두 다시 */
|
||||
const moveTo = async (key: string, lon: number, lat: number): Promise<void> => {
|
||||
const address = key === "start" ? "" : await reverseAddress(projectId, lon, lat);
|
||||
const fresh = liveMilestone(projectId);
|
||||
if (key === "start") fresh.site.start_pin = { lon, lat };
|
||||
else {
|
||||
const target = ownerOf(fresh, key);
|
||||
if (!target) return;
|
||||
Object.assign(target, { lon, lat, address });
|
||||
}
|
||||
putMilestone(projectId, fresh);
|
||||
await refind(touchingSegments(fresh, key));
|
||||
};
|
||||
const map = editMap(projectId, {
|
||||
hint: "",
|
||||
onMove: (key, lon, lat) =>
|
||||
void reverseAddress(projectId, lon, lat).then((addr) => {
|
||||
const fresh = liveMilestone(projectId);
|
||||
const target = ownerOf(fresh, key);
|
||||
if (!target) return;
|
||||
Object.assign(target, { lon, lat, address: addr });
|
||||
putMilestone(projectId, fresh);
|
||||
void findSegmentKm(projectId, key).then(() => drawMap());
|
||||
}),
|
||||
onMove: (key, lon, lat) => void moveTo(key, lon, lat),
|
||||
});
|
||||
const drawMap = (): void => map.set(pins(), lines());
|
||||
let asked = false; // 길 선이 아직 없으면 열 때 한 번 길찾기(그림 · 알림 없음)
|
||||
let asked = false; // 길 선이 없으면 열 때 한 번 길찾기(사슬 선 모두 · 그림 · 알림 없음)
|
||||
const ensurePath = (): void => {
|
||||
if (asked || segmentPath(routeId).length) return;
|
||||
if (asked) return;
|
||||
asked = true;
|
||||
void findSegmentKm(projectId, routeId, false).then(() => drawMap());
|
||||
const d = liveMilestone(projectId);
|
||||
const r = d.routes.find((x) => x.id === routeId);
|
||||
const chain = r && r.linked !== false ? chainOf(d, r.to).map((b) => b.id) : [];
|
||||
const missing = [routeId, ...chain].filter((id) => !segmentPath(id).length);
|
||||
if (missing.length) void refind(missing);
|
||||
};
|
||||
|
||||
const root = el("section", {
|
||||
@@ -173,6 +196,7 @@ export function routeView(projectId: string, routeId: string): MilestoneView {
|
||||
el("div", { className: "b08-milestone__bar", children: [change, reset, file] }),
|
||||
],
|
||||
}),
|
||||
status,
|
||||
img,
|
||||
map.root,
|
||||
],
|
||||
|
||||
@@ -24,6 +24,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { request } from "./B08_Quantity_UI_Hydro";
|
||||
import {
|
||||
PAVED,
|
||||
SITE_ID,
|
||||
migrateMilestone,
|
||||
type MilestoneData,
|
||||
type SegKind,
|
||||
@@ -71,6 +72,19 @@ export async function loadMilestone(projectId: string, force = false): Promise<P
|
||||
|
||||
export const siteInfo = (): SiteInfo | null => saved?.site ?? null;
|
||||
|
||||
/** 길찾기 시점 = 경로 탭에서 끌어 놓은 덮개(255-2 · site.start_pin) · 없으면 노선 첫 점 */
|
||||
export const startPoint = (data: MilestoneData): [number, number] | null => {
|
||||
const pin = data.site.start_pin;
|
||||
return pin ? [pin.lon, pin.lat] : (siteInfo()?.start_lonlat ?? null);
|
||||
};
|
||||
|
||||
/** 그 자리(시점 "start" · 기준점 · 거래처 id)에 닿는 구간(255-2) — 나가는 구간(자기) + 들어오는 구간(to = 그 자리) */
|
||||
export function touchingSegments(data: MilestoneData, key: string): string[] {
|
||||
const to = key === "start" ? SITE_ID : key;
|
||||
const incoming = [...data.bases, ...data.routes].filter((o) => o.to === to && o.linked !== false);
|
||||
return [...(key === "start" ? [] : [key]), ...incoming.map((o) => o.id)];
|
||||
}
|
||||
|
||||
/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 ·
|
||||
* 오면 다시 그림) · 아직 · 실패 = null(좌표만) */
|
||||
const siteAddresses = new Map<string, SiteAddress | null>();
|
||||
@@ -192,10 +206,11 @@ export async function autoRouteImage(
|
||||
lines: Array<Array<[number, number]>>,
|
||||
): Promise<void> {
|
||||
// 표지 = 거래처 쪽 구간 두 끝(mapMarks 차례 = 거래처 · 기준점(거침) · 시점 → 앞 둘)
|
||||
const marks = mapMarks(liveMilestone(projectId), routeId, siteInfo()?.start_lonlat ?? null).slice(
|
||||
0,
|
||||
2,
|
||||
);
|
||||
const marks = mapMarks(
|
||||
liveMilestone(projectId),
|
||||
routeId,
|
||||
startPoint(liveMilestone(projectId)),
|
||||
).slice(0, 2);
|
||||
let reason: string | null;
|
||||
try {
|
||||
const res = await request<{ name: string | null; reason: string | null }>(
|
||||
@@ -280,7 +295,7 @@ export async function findSegmentKm(
|
||||
const data = liveMilestone(projectId);
|
||||
const owner = ownerOf(data, from);
|
||||
if (owner?.manual) return ""; // 155 수기 입력 = 카카오 길찾기 안 함
|
||||
const seg = segmentEnds(data, from, siteInfo()?.start_lonlat ?? null);
|
||||
const seg = segmentEnds(data, from, startPoint(data));
|
||||
if (!seg) return "";
|
||||
if (!seg.from || !seg.to) {
|
||||
const missing = seg.text.split(" → ")[seg.from ? 1 : 0];
|
||||
|
||||
@@ -490,11 +490,10 @@ def compute_bill(
|
||||
gaps.extend(row.pop("_gaps", []))
|
||||
row.pop("_home", None)
|
||||
row["no"] = ""
|
||||
if row["kind"] == "title":
|
||||
empty = f" 빈 줄 {row['blank']}" if row["blank"] else ""
|
||||
row["note"] = ("(제외금액)" if row["key"] == "sec:G" else "") + empty
|
||||
if row["kind"] == "title": # 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로)
|
||||
row["note"] = "(제외금액)" if row["key"] == "sec:G" else ""
|
||||
elif row["kind"] == "group":
|
||||
row["note"] = f"빈 줄 {row['blank']}" if row["blank"] else ""
|
||||
row["note"] = ""
|
||||
if row["kind"] != "item" and row["cost"] == "direct" and row["level"] <= 2:
|
||||
up = row["parent"] # 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||
seq[up] = seq.get(up, 0) + 1
|
||||
|
||||
@@ -458,10 +458,9 @@ export function computeBill(
|
||||
delete row._gaps;
|
||||
delete row._home;
|
||||
row.no = "";
|
||||
if (row.kind === "title")
|
||||
row.note =
|
||||
(row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : "");
|
||||
else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : "";
|
||||
// 257-2 비고 「빈 줄 n」 없음(blank 수는 줄 값 그대로)
|
||||
if (row.kind === "title") row.note = row.key === "sec:G" ? "(제외금액)" : "";
|
||||
else if (row.kind === "group") row.note = "";
|
||||
if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) {
|
||||
// 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
|
||||
const up = row.parent;
|
||||
|
||||
@@ -62,7 +62,13 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs_Summary";
|
||||
import { buildCost, buildTotal } from "./B09_Estimation_UI_Tabs_Total";
|
||||
import { buildSg, buildSgList } from "./B09_Estimation_UI_Tabs_Sangeun";
|
||||
import { isGroupTab, lineHint, pickClass, renderGroupPanel } from "./B09_Estimation_UI_Tabs_Groups";
|
||||
import {
|
||||
isGroupTab,
|
||||
lineHint,
|
||||
pickClass,
|
||||
renderGroupPanel,
|
||||
unpickOnRelease,
|
||||
} from "./B09_Estimation_UI_Tabs_Groups";
|
||||
|
||||
// 252 초안 탭(공기 등) — draft 폴더가 있으면 등록 · 지우면 빈 목록(오류 없음)
|
||||
import.meta.glob("./draft/*_UI.ts", { eager: true });
|
||||
@@ -274,13 +280,17 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const bill = (spec as BillTable).menu ? (spec as BillTable) : null;
|
||||
const at = focus === undefined ? -1 : rowIndex(spec.rows, focus);
|
||||
if (bill) jumpTo(bill.rows, at);
|
||||
// 257-2 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(그 줄 하나 · 줄 누름 · Esc 로 풂)
|
||||
const range: [number, number] | null = !bill && at >= 0 ? [at, at] : null;
|
||||
const rangeClass = range ? (i: number) => pickClass(range, i) : undefined;
|
||||
const table = makeTable(
|
||||
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : undefined,
|
||||
bill ? (i) => pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i) : rangeClass,
|
||||
);
|
||||
if (range) unpickOnRelease(table.root);
|
||||
const level = bill ? levelBar(table.root, bill, (i) => table.edit(i)) : null;
|
||||
if (level) bars.setTools(level.tools);
|
||||
panel.replaceChildren(table.root, ...(level ? [level.guide] : []), ...(hint ? [hint] : []));
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, !bill)); // 설계내역서 = 스크롤만(표시 = 빨강)
|
||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
||||
};
|
||||
|
||||
const apply = (next: Row, fromServer: boolean): void => {
|
||||
|
||||
@@ -203,6 +203,17 @@ function listenKeys(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/** 257-2 이동해 온 줄 빨강(묶음 아닌 표 · 설계내역서 빼고) — 줄 누름 · 방향키 · Esc 로 풂(다시 그리지 않음) */
|
||||
export function unpickOnRelease(root: HTMLElement): void {
|
||||
const clear = (): void => {
|
||||
for (const tr of root.querySelectorAll("tr.is-pick"))
|
||||
tr.classList.remove("is-pick", "is-pick-top", "is-pick-end");
|
||||
};
|
||||
root.addEventListener("click", clear);
|
||||
live = { root, unpick: clear };
|
||||
listenKeys();
|
||||
}
|
||||
|
||||
/** 이 화면 안 보기(기억이 안 돼도 바꾼 보기 유지) */
|
||||
const chosen = new Map<TabId, GroupView>();
|
||||
|
||||
|
||||
@@ -223,8 +223,9 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
`순자재대 ${won(supply.net ?? null)} + 조달수수료 ${won(supply.fee ?? null)} → 천원 올림`,
|
||||
{ hand: hand("supply") },
|
||||
),
|
||||
row("grand", "바.", "총공사비", sub.grand ?? null),
|
||||
];
|
||||
// 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 몸 줄 아님 · 줄 이동 누름 없음)
|
||||
const grand = row("grand", "바.", "총공사비", sub.grand ?? null);
|
||||
const safetyRow = rows.find((r) => r.key === "safety");
|
||||
if (safetyRow && safety.a !== null && safety.a !== undefined)
|
||||
safetyRow.note = `A(관급 ÷ 1.1 포함) ${won(safety.a)} · B(× 1.2) ${won(safety.b)} → 작은 쪽`;
|
||||
@@ -245,6 +246,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
||||
text("candidate", "요율 출처", 260),
|
||||
],
|
||||
rows,
|
||||
total: grand,
|
||||
frozen: 2,
|
||||
rowClass: (i) => levelClass(rows[i]),
|
||||
level: nameLevel(rows),
|
||||
@@ -288,8 +290,7 @@ const COST_ROWS: Array<[string, string, string, string, string?]> = [
|
||||
["vat", "부가가치세", "", "", "vat"],
|
||||
["contract", "도급공사비", "", "", "contract"],
|
||||
["supply", "관급자재대", "", "", "supply"],
|
||||
["grand", "총공사비", "", "", "grand"],
|
||||
];
|
||||
]; // 총공사비(grand) = 표 아래 고정 줄(257-2 · buildCost total)
|
||||
|
||||
export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
const t: Row = ctx.sheets.total || {};
|
||||
@@ -314,6 +315,14 @@ export const buildCost: TabBuild = (ctx): TabTable => {
|
||||
level: !part ? 1 : name === "소계" ? 2 : 3,
|
||||
}));
|
||||
return {
|
||||
// 257-2 총공사비 = 표 아래 고정 줄(템플릿 total · 줄 이동 누름 없음)
|
||||
total: {
|
||||
key: "grand",
|
||||
group: "총공사비",
|
||||
amount: cost.grand ?? null,
|
||||
share: share.grand ?? null,
|
||||
note: formula.grand ?? "",
|
||||
},
|
||||
columns: [
|
||||
text("group", "구분", 96),
|
||||
text("part", "비목", 72),
|
||||
|
||||
@@ -124,7 +124,7 @@ def test_제목_합_빈_줄과_원가계산서_이음():
|
||||
assert keys[:3] == ["sec:1", "sec:1.1", "sec:1.2"] and keys[-1] == "sec:G"
|
||||
assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
|
||||
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수
|
||||
assert rows["sec:1"]["blank"] == 1 and "빈 줄 1" in rows["sec:1"]["note"]
|
||||
assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음
|
||||
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
|
||||
assert sheets["bill"]["sums"] == {
|
||||
"material": None,
|
||||
|
||||
@@ -191,7 +191,7 @@ t = buildTotal(ctx());
|
||||
t.onEdit(at(t.rows, "care"), "rate", "");
|
||||
out.careGone = "care" in user.estimate.rates;
|
||||
t = buildTotal(ctx());
|
||||
out.grandBlank = t.rows[at(t.rows, "grand")].amount;
|
||||
out.grandBlank = t.total.amount; // 257-2 총공사비 = 표 아래 고정 줄
|
||||
t.onEdit(at(t.rows, "safety"), "hand", "1,000");
|
||||
out.base = user.estimate.safety_base;
|
||||
const c = buildCost(ctx());
|
||||
|
||||
@@ -121,7 +121,7 @@ def test_묶음_금액_자손_합_공종_합_그대로():
|
||||
rows = _rows(sheets)
|
||||
g2, g1 = rows["hand:g2"], rows["hand:g1"]
|
||||
assert (g2["sum"], g2["labor"], g2["material"], g2["expense"]) == (2460, 2000, 400, 60)
|
||||
assert (g2["blank"], g2["note"]) == (1, "빈 줄 1")
|
||||
assert (g2["blank"], g2["note"]) == (1, "") # 257-2 비고 빈 줄 없음
|
||||
assert (g1["sum"], g1["labor"], g1["blank"]) == (12300 + 2460, 12000, 1) # a + b(+ c 빈 줄)
|
||||
assert rows["hand:p"]["sum"] == 0 and rows["hand:p"]["note"] == ""
|
||||
# 공종 합 · 원가계산서 sums · 계 = 묶음 없을 때와 같음(묶음 줄 안 셈)
|
||||
|
||||
@@ -21,7 +21,8 @@ BODY = f"""
|
||||
const user = {{ estimate: {{}} }};
|
||||
const ctx = {{ sheets: {{ total: computeTotal({json.dumps(SUMS)}, user.estimate) }}, payload: {{}}, user, setUser: () => {{}} }};
|
||||
const pack = (t) => t.rows.map((r, i) => [r.key, r.level, t.rowClass(i), t.level.key, t.level.of(i), t.cellClass ?? null]);
|
||||
console.log(JSON.stringify({{ total: pack(buildTotal(ctx)), cost: pack(buildCost(ctx)) }}));
|
||||
const T = buildTotal(ctx), C = buildCost(ctx);
|
||||
console.log(JSON.stringify({{ total: pack(T), cost: pack(C), fixed: {{ total: T.total, cost: C.total }} }}));
|
||||
"""
|
||||
|
||||
|
||||
@@ -41,8 +42,9 @@ def _run() -> dict:
|
||||
|
||||
def test_총괄_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["total"]}
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract", "grand"]
|
||||
top = ["direct", "net_cost", "da", "supply_price", "contract"]
|
||||
assert all(rows[k] == (1, "is-title") for k in top)
|
||||
assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total)
|
||||
for k in ["in_price", "in_env", "indirect_labor", "machine", "retirement", "overhead", "vat",
|
||||
"supply"]: # fmt: skip
|
||||
assert rows[k] == (2, None), k
|
||||
@@ -51,8 +53,9 @@ def test_총괄_레벨():
|
||||
|
||||
def test_원가계산서_레벨():
|
||||
rows = {k: (lv, cls) for k, lv, cls, *_ in _run()["cost"]}
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply", "grand"]:
|
||||
for k in ["overhead", "profit", "supply_price", "vat", "contract", "supply"]:
|
||||
assert rows[k] == (1, "is-title"), k
|
||||
assert "grand" not in rows # 257-2 총공사비 = 표 아래 고정 줄(total)
|
||||
for k in ["material", "labor", "expense_sum"]:
|
||||
assert rows[k] == (2, None), k
|
||||
for k in ["direct_material", "scrap", "direct_labor", "indirect_labor", "expense", "safety"]:
|
||||
@@ -74,3 +77,10 @@ def test_CSS_옛_레벨_규칙_없음_공용_표로():
|
||||
r"td\.ui-data-table__lv \{\s*padding-left: calc\(var\(--spacing-8\) \+ var\(--dt-lv, 0\) \* var\(--dt-indent\)\);",
|
||||
common,
|
||||
)
|
||||
|
||||
|
||||
def test_총공사비_아래_고정_줄_총괄_원가계산서():
|
||||
fixed = _run()["fixed"]
|
||||
assert fixed["total"]["key"] == "grand" and fixed["total"]["name"] == "총공사비"
|
||||
assert fixed["cost"]["key"] == "grand" and fixed["cost"]["group"] == "총공사비"
|
||||
assert fixed["cost"]["amount"] == fixed["total"]["amount"] # 원가계산서 = 총괄 거울
|
||||
|
||||
@@ -122,7 +122,7 @@ def test_Shell_배선_공용_표_스크롤만():
|
||||
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "if (bill) jumpTo(bill.rows, at);" in shell
|
||||
assert "pickedClass(bill.rows[i]) ?? pickClass(jumpRange(), i)" in shell
|
||||
assert "table.focusRow(at, !bill)" in shell
|
||||
assert "table.focusRow(at, false)" in shell # 257-2 모든 탭 빨강 = 스크롤만
|
||||
common = (ROOT / "ui_template" / "ui_template_data_table.ts").read_text("utf-8")
|
||||
assert "focusRow: (index: number, mark?: boolean) => void;" in common
|
||||
assert "state.focus = mark ? index : null;" in common
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""255-2 거리이정표 경로 탭 — 시점 끌기 = 길찾기용 덮개(site.start_pin · 노선 그대로) ·
|
||||
표지 옮기면 그 자리에 닿는 구간(나가는 · 들어오는) 모두 다시 길찾기 · 열 때 사슬 구간 모두 선 ·
|
||||
실패 안내 · 끌기는 그림 캡처 안 부름."""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
STUBS = """
|
||||
let cache = null;
|
||||
const sent = [];
|
||||
const API_BASE_URL = "";
|
||||
const showToast = () => undefined;
|
||||
const readState = () => cache;
|
||||
const writeState = (_key, d) => { cache = d; };
|
||||
const clearState = () => { cache = null; };
|
||||
const request = async (url, opts) => {
|
||||
if (url.endsWith("/quantity/milestone"))
|
||||
return { data: DATA, site: { length_m: 1000, mid_default_m: 500, start_lonlat: [127, 35],
|
||||
mid_lonlat: null }, images: {}, auto_images: {} };
|
||||
sent.push(JSON.parse(opts.body));
|
||||
return { km: 1.5, reason: null, path: [] };
|
||||
};
|
||||
"""
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _place(pid, to, lon, lat, **more):
|
||||
return {"id": pid, "name": pid, "address": "", "dir": None, "lon": lon, "lat": lat,
|
||||
"legs": [], "to": to, **more} # fmt: skip
|
||||
|
||||
|
||||
DATA = {
|
||||
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
||||
"bases": [_place("b1", "site", 127.5, 35.5), _place("b2", "b1", 127.6, 35.6)],
|
||||
"routes": [
|
||||
_place("r1", "site", 127.2, 35.2, rows=[]),
|
||||
_place("r2", "b1", 127.3, 35.3, rows=[]),
|
||||
_place("r3", "b2", 127.4, 35.4, rows=[]),
|
||||
_place("r4", "b1", 127.1, 35.1, rows=[], linked=False),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _run(script):
|
||||
src = "".join(
|
||||
IMPORTS.sub("", _read(f"B08_Quantity/B08_Quantity_{n}.ts"))
|
||||
for n in ("Milestone_Calc", "Milestone_Layout", "UI_Milestone_State")
|
||||
)
|
||||
code = f"const DATA = {json.dumps(DATA)};\n{STUBS}\n{src}\n{script}"
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
|
||||
input=code, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_시점_덮개_길찾기():
|
||||
got = _run("""
|
||||
await loadMilestone("p");
|
||||
await findSegmentKm("p", "r1", false);
|
||||
const d = liveMilestone("p");
|
||||
d.site.start_pin = { lon: 128.25, lat: 36.5 };
|
||||
putMilestone("p", d);
|
||||
await findSegmentKm("p", "r1", false);
|
||||
await findSegmentKm("p", "b1", false);
|
||||
console.log(JSON.stringify({ sent, start: startPoint(liveMilestone("p")) }));
|
||||
""")
|
||||
dest = [s["destination"] for s in got["sent"]]
|
||||
assert dest[0] == {"lon": 127, "lat": 35} # 덮개 없음 = 노선 첫 점
|
||||
assert dest[1] == dest[2] == {"lon": 128.25, "lat": 36.5} # 거래처 · 기준점 → 시점 = 덮개
|
||||
assert got["start"] == [128.25, 36.5]
|
||||
|
||||
|
||||
def test_닿는_구간_모두():
|
||||
got = _run("""
|
||||
console.log(JSON.stringify(["start", "b1", "b2", "r1", "r3"].map((k) => touchingSegments(DATA, k))));
|
||||
""")
|
||||
start, b1, b2, r1, r3 = got
|
||||
assert start == ["b1", "r1"] # 시점에 들어오는 구간
|
||||
assert b1 == ["b1", "b2", "r2"] # 나가는 구간 + 들어오는 경로들(안 이은 r4 빠짐)
|
||||
assert b2 == ["b2", "r3"]
|
||||
assert r1 == ["r1"] and r3 == ["r3"]
|
||||
|
||||
|
||||
def test_서버_모델_시점_덮개():
|
||||
got = ms.Milestone.model_validate({"site": {"start_pin": {"lon": 127.5, "lat": 35.5}}})
|
||||
assert got.model_dump()["site"]["start_pin"] == {"lon": 127.5, "lat": 35.5}
|
||||
assert ms.Milestone().model_dump()["site"]["start_pin"] is None
|
||||
with pytest.raises(ValidationError):
|
||||
ms.Milestone.model_validate({"site": {"start_pin": {"lon": 500, "lat": 35}}})
|
||||
|
||||
|
||||
def test_경로_탭_끌기_다시_길찾기():
|
||||
route = _read("B08_Quantity/B08_Quantity_UI_Milestone_Route.ts")
|
||||
assert "movable: true" in route and 'movable: key !== "start"' not in route
|
||||
assert "fresh.site.start_pin = { lon, lat };" in route
|
||||
assert "await refind(touchingSegments(fresh, key));" in route
|
||||
assert "findSegmentKm(projectId, id, false)" in route # 끌기 · 열기 = 그림 캡처 없음
|
||||
assert "[routeId, ...chain].filter((id) => !segmentPath(id).length)" in route # 열 때 모든 구간
|
||||
assert "status.hidden = !bad.length;" in route # 실패 안내
|
||||
calc = _read("B08_Quantity/B08_Quantity_Milestone_Calc.ts")
|
||||
assert "start_pin?: { lon: number; lat: number } | null;" in calc
|
||||
@@ -0,0 +1,64 @@
|
||||
"""257-2 B09 이동해 온 줄 = 모든 탭 빨강 상자 · 비고 「빈 줄 N」 없음 · 총공사비 = 표 아래 고정 줄.
|
||||
|
||||
- 쉘 = 설계내역서 밖 · 묶음 아닌 탭에도 줄 범위 [at, at] 빨강(pickClass) · 줄 누름 · Esc · 방향키로 풂(unpickOnRelease)
|
||||
- 비고 = 제목 줄 「(제외금액)」만 · 묶음 줄 빈칸(Py · TS 짝 · blank 수는 줄 값 그대로)
|
||||
- 총괄 · 원가계산서 총공사비(grand) = `TabTable.total`(몸 줄에서 빠짐 · 줄 이동 누름 없음 — 185-1 시험이 값 확인)
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from resources.tester.helper_b09_group_cards import COMMON, GROUPS, RAW, TABS, run # noqa: E402
|
||||
|
||||
B09 = ROOT / "B09_Estimation"
|
||||
|
||||
|
||||
def test_쉘_모든_탭_빨강_배선():
|
||||
shell = (B09 / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||
assert "!bill && at >= 0 ? [at, at] : null" in shell
|
||||
assert "pickClass(range, i)" in shell and "unpickOnRelease(table.root)" in shell
|
||||
assert "table.focusRow(at, false)" in shell
|
||||
|
||||
|
||||
def test_풂_줄_누름_Esc_빨강만_지움():
|
||||
out = run(
|
||||
[COMMON, TABS, GROUPS],
|
||||
"""
|
||||
const root = document.createElement("div");
|
||||
const rows = [0, 1, 2].map((i) => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.dataset.i = String(i);
|
||||
if (i === 1) tr.classList.add("is-pick", "is-pick-top", "is-pick-end");
|
||||
root.append(tr);
|
||||
return tr;
|
||||
});
|
||||
unpickOnRelease(root);
|
||||
const before = rows.map((t) => t.className);
|
||||
key("Escape");
|
||||
const afterEsc = rows.map((t) => t.className);
|
||||
rows[1].classList.add("is-pick");
|
||||
root.click();
|
||||
const afterClick = rows.map((t) => t.className);
|
||||
console.log(JSON.stringify({ before, afterEsc, afterClick }));
|
||||
""",
|
||||
RAW
|
||||
+ """
|
||||
E.prototype.querySelectorAll = function () { return this.all("TR").filter((t) => t.cls.has("is-pick")); };
|
||||
""",
|
||||
)
|
||||
assert out["before"] == ["", "is-pick is-pick-top is-pick-end", ""]
|
||||
assert out["afterEsc"] == ["", "", ""]
|
||||
assert out["afterClick"] == ["", "", ""]
|
||||
|
||||
|
||||
def test_비고_빈_줄_없음_Py_TS_짝():
|
||||
py = (B09 / "B09_Estimation_Calc_Bill.py").read_text("utf-8")
|
||||
ts = (B09 / "B09_Estimation_Calc_Bill.ts").read_text("utf-8")
|
||||
assert "빈 줄 {" not in py and not re.search(r"빈 줄 \$\{", ts)
|
||||
assert 'row["note"] = "(제외금액)" if row["key"] == "sec:G" else ""' in py
|
||||
assert 'row.note = row.key === "sec:G" ? "(제외금액)" : ""' in ts
|
||||
Reference in New Issue
Block a user