Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -351,9 +351,11 @@ export class TableEntity implements Entity {
|
||||
data.cells,
|
||||
data.style
|
||||
);
|
||||
table.id = jsonEntity.id;
|
||||
table.lineColor = jsonEntity.lineColor;
|
||||
table.lineWidth = jsonEntity.lineWidth;
|
||||
// 서버가 만든 표(면적산출 → 도면 · M02 41장)는 id · 선 색 · 굵기가 없을 수 있다 — 비면 새 표의
|
||||
// 기본값(선 색 = 자동색 `#fff` · 바탕 따라 흰/검정)으로 채운다. 놓기 · [업데이트] · 다시 열기가 모두 여기를 지난다.
|
||||
table.id = jsonEntity.id || table.id;
|
||||
table.lineColor = jsonEntity.lineColor || table.lineColor;
|
||||
table.lineWidth = jsonEntity.lineWidth ?? table.lineWidth;
|
||||
table.lineDash = jsonEntity.lineDash;
|
||||
return table;
|
||||
}
|
||||
|
||||
@@ -77,6 +77,28 @@ describe('표 놓기(PLACETABLE)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('선 색 없는 표(서버 표 변환 답 · 43-6)', () => {
|
||||
it('id · 선 색 · 굵기가 없으면 기본값(자동색 #fff · 1 · 새 id)으로 채워 놓고 저장 JSON 에도 남음', async () => {
|
||||
const json = await table('T8').toJson();
|
||||
if (!json) throw new Error('표 JSON 없음');
|
||||
const { id: _id, lineColor: _color, lineWidth: _width, layerId: _layer, ...bare } = json;
|
||||
await handleTableMessage(
|
||||
{ type: AISLO_PLACE_TABLE_MESSAGE, entity: bare as typeof json, link: 'T8' },
|
||||
vi.fn()
|
||||
);
|
||||
const pending = getPendingTable();
|
||||
expect(pending?.lineColor).toBe('#fff');
|
||||
expect(pending?.lineWidth).toBe(1);
|
||||
const placed = tableAt(pending as TableEntity, new Point(0, 0), layer.id);
|
||||
placed.lineColor = pending?.lineColor ?? '';
|
||||
const saved = await placed.toJson();
|
||||
expect(saved?.lineColor).toBe('#fff');
|
||||
const back = await TableEntity.fromJson(bare as typeof json);
|
||||
expect(back.id).toBeTruthy();
|
||||
expect(back.lineColor).toBe('#fff');
|
||||
});
|
||||
});
|
||||
|
||||
describe('표 칸 갈기(update-table)', () => {
|
||||
it('연결이 같은 표의 칸만 갈고 자리 · 크기는 그대로 · 되돌리기 한 걸음', () => {
|
||||
const linked = table('T1');
|
||||
|
||||
@@ -325,11 +325,8 @@ def _reflected(snapshot: Any, labor: dict[str, Any], lines: list[dict], area: An
|
||||
mats = []
|
||||
for one in doc.get("자재") or []:
|
||||
code = str(one.get("코드") or "")
|
||||
try:
|
||||
price, _ = mm.element(snapshot, code, {}, one.get("단위") or None)
|
||||
except mf.FormulaError:
|
||||
price = None
|
||||
mats.append({"코드": code, "알약": labor_doc.material_key(code), "기준단가": price})
|
||||
got = labor_doc.material_price(snapshot, code, one.get("단위")) # 가격 다섯 · 기준칸(43-3)
|
||||
mats.append({"코드": code, "알약": labor_doc.material_key(code), **got})
|
||||
settled = labor_doc.settle(
|
||||
to_json({"줄": base, "자재": mats}), doc, labor_doc.area_values(area)
|
||||
)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||||
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
|
||||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값.
|
||||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값 · 자재 줄에 값 열 다섯(`가격`) ·
|
||||
기준단가가 된 칸 이름(`기준칸`)도 실음(43-3).
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
"""
|
||||
@@ -247,6 +248,24 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||||
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
|
||||
|
||||
|
||||
def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]:
|
||||
"""자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element`
|
||||
(값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만."""
|
||||
row = master.get(code) or {}
|
||||
prices = {slot: row.get(slot) for slot in mm.SLOTS}
|
||||
try:
|
||||
price, source = mm.element(master, code, {}, unit or None)
|
||||
except mf.FormulaError as e:
|
||||
return {"기준단가": None, "가격": prices, "기준칸": None, "까닭": str(e)}
|
||||
slot = str(source).split(".")[-1] if source else None
|
||||
return {
|
||||
"기준단가": price,
|
||||
"가격": prices,
|
||||
"기준칸": slot if slot in mm.SLOTS else None,
|
||||
"까닭": None if price is not None else "단가 없음",
|
||||
}
|
||||
|
||||
|
||||
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
|
||||
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
|
||||
@@ -277,17 +296,14 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
|
||||
continue
|
||||
lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None})
|
||||
mats = []
|
||||
for one in doc.get("자재") or []:
|
||||
try:
|
||||
price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None)
|
||||
except mf.FormulaError as e:
|
||||
price, why = None, str(e)
|
||||
else:
|
||||
why = None if price is not None else "단가 없음"
|
||||
mats.append(
|
||||
{"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why}
|
||||
)
|
||||
mats = [
|
||||
{
|
||||
"코드": one["코드"],
|
||||
"알약": material_key(one["코드"]),
|
||||
**material_price(whole, one["코드"], one.get("단위")),
|
||||
}
|
||||
for one in doc.get("자재") or []
|
||||
]
|
||||
have = _area_of(doc.get("열")) if given is None else given
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
@@ -19,7 +19,12 @@ import type {
|
||||
SpreadsheetHandle,
|
||||
Workbook,
|
||||
} from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { StructureDoc, StructureNumbers } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type StructureDoc,
|
||||
type StructureNumbers,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
cardShell,
|
||||
helpButton,
|
||||
@@ -35,7 +40,7 @@ import {
|
||||
type DrawingCardHandle,
|
||||
} from "./M02_MasterTemplete_DrawingCard";
|
||||
import type { ItemPart, ItemSource } from "./M02_MasterTemplete_ItemSource";
|
||||
import type { LaborViewHandle } from "./M02_MasterTemplete_Labor_View";
|
||||
import type { LaborDetailHandle } from "./M02_MasterTemplete_Labor_Detail";
|
||||
import "./M02_MasterTemplete_Structure.css";
|
||||
|
||||
/** 집계표 열 하나에 딸린 문서 셋 — 번호 칸 · 이름 */
|
||||
@@ -124,7 +129,7 @@ export interface ItemCardsOptions {
|
||||
/** 카드 [삭제](40-2) — 그 항목 비우기(번호 · 열 그대로 · 물음은 카드가 한 번) · 없으면 단추 없음 ·
|
||||
* 끝나면 카드를 다시 읽음(빈 카드 = [새로 만들기] [복사 생성]) */
|
||||
onClear?: (kind: CardKind) => Promise<void>;
|
||||
/** 일위대가 카드(39-5 · 몸 = 일위대가 보기 갈래 `Labor_View`) — 도면 다음 · 지금 층에서 읽음 ·
|
||||
/** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 ·
|
||||
* [일위대가 수정] · 빈 카드 [새로 만들기] = 이것(일위대가 상세) */
|
||||
labor?: () => void;
|
||||
}
|
||||
@@ -327,35 +332,57 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
});
|
||||
paintPills(readStateRaw("m02-card-pills") === "true");
|
||||
sheets[0].shell.tools.prepend(pills);
|
||||
// 일위대가 카드 — 보기만(고침 = [일위대가 수정] 상세) · 부품은 처음 열 때 받음
|
||||
// 일위대가 카드(43-4) — 상세의 단가산출 로직 표(2행) + 자재목록 그대로(`Labor_Detail` 카드 갈래 ·
|
||||
// 순서 · 로직 추가 · 다이어그램 없음) · 고침은 카드 [저장] · Ctrl+S · 부품은 처음 열 때 받음
|
||||
const laborHost = el("div", { className: "m02-struct__labor" });
|
||||
const laborCard = opts.labor
|
||||
? makeCard("labor", PART_WORD.labor, laborHost, () => Promise.resolve())
|
||||
? makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())
|
||||
: null;
|
||||
let laborView: LaborViewHandle | null = null;
|
||||
/** 일위대가 카드 — 그 열 문서 · calc 를 받아 그림 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */
|
||||
let laborEd: LaborDetailHandle | null = null;
|
||||
let laborVer = "";
|
||||
/** 일위대가 카드 — 그 열 문서를 읽어 카드 갈래로 붙임 · 빈 일위대가(로직 · 자재 없음)면 두 단추 */
|
||||
const paintLabor = async (at: string, mine: number): Promise<void> => {
|
||||
if (!laborCard) return;
|
||||
laborEd?.destroy();
|
||||
laborEd = null;
|
||||
const layer = opts.source().layer;
|
||||
let mod: typeof import("./M02_MasterTemplete_Labor_Detail");
|
||||
let got: Awaited<ReturnType<typeof readTemplate>>;
|
||||
try {
|
||||
// 카드 = 한 줄 표(항목 · 수량산출 · 단위 · 금액 넷 · 40-2) — 자세한 것은 [수정] 상세
|
||||
laborView ??= (await import("./M02_MasterTemplete_Labor_View")).createLaborView(undefined, {
|
||||
compact: true,
|
||||
});
|
||||
[mod, got] = await Promise.all([
|
||||
import("./M02_MasterTemplete_Labor_Detail"),
|
||||
readTemplate(layer, "labor", at, null),
|
||||
]);
|
||||
} catch (error) {
|
||||
if (mine === seq) laborCard.shell.fail(fill(TEXT.noSheet, why(error)), reload);
|
||||
return;
|
||||
}
|
||||
if (mine !== seq) return;
|
||||
const view = laborView;
|
||||
laborHost.replaceChildren(view.root);
|
||||
await view.show(opts.source().layer, at);
|
||||
if (mine !== seq) return;
|
||||
const d = view.doc();
|
||||
laborVer = got.판 ?? "";
|
||||
const d = (got.문서 ?? {}) as { 일위대가번?: string; 담은로직?: unknown[]; 자재?: unknown[] };
|
||||
laborCard.no.textContent =
|
||||
d?.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
|
||||
if (blank(laborCard, !!d && !d.담은로직.length && !d.자재.length))
|
||||
laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.()));
|
||||
d.일위대가번 || numberOf("labor", at) || fill(TEXT.noFieldNumber, PART_FIELD.labor);
|
||||
if (blank(laborCard, !d.담은로직?.length && !d.자재?.length))
|
||||
return void laborHost.replaceChildren(startBox(laborCard, () => opts.labor?.()));
|
||||
laborEd = mod.mountLaborDetail(laborHost, got.문서, {
|
||||
layer,
|
||||
column: at,
|
||||
readOnly: !opts.editable(),
|
||||
onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable()),
|
||||
goArea: null, // 면적 칸 잇기는 수량 모달 안(43-1) — 카드에서 화면을 옮기지 않음
|
||||
card: true,
|
||||
});
|
||||
};
|
||||
/** 카드 [저장] — 상세와 같은 차례: 고른 칸 연결(면적 문서) 먼저 → 일위대가(판을 다시 읽어 409 막음) */
|
||||
async function saveLabor(): Promise<void> {
|
||||
const at = column;
|
||||
if (!laborEd || !at) return;
|
||||
const layer = opts.source().layer;
|
||||
const areaSaved = await laborEd.saveArea();
|
||||
if (areaSaved) laborVer = (await readTemplate(layer, "labor", at, null)).판 ?? "";
|
||||
laborVer = (await saveTemplate(layer, "labor", at, null, laborVer, laborEd.getDoc())).판;
|
||||
if (areaSaved) window.setTimeout(reload, 0); // 면적산출 카드도 새 연결로
|
||||
}
|
||||
const cards: Card[] = [info, ...(laborCard ? [laborCard] : []), ...sheets];
|
||||
const defaults: Record<CardKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
@@ -375,8 +402,10 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const drawEdit = drawingEditable(opts.editable(), opts.source());
|
||||
const sheetEdit = !!opts.sheetEdit?.can();
|
||||
for (const c of cards) {
|
||||
const can = c.kind === "structure" ? drawEdit : c.kind !== "labor" && sheetEdit;
|
||||
const can =
|
||||
c.kind === "structure" ? drawEdit : c.kind === "labor" ? opts.editable() : sheetEdit;
|
||||
c.saver.button.hidden = c.help.hidden = !can;
|
||||
if (c.kind === "labor") c.help.hidden = true; // 일위대가 카드엔 단축키 상자 없음
|
||||
c.edit.hidden = !opts.editable();
|
||||
c.del.hidden = !opts.editable() || !opts.onClear; // 빈 카드는 그린 뒤 `blank` 가 다시 숨김
|
||||
}
|
||||
@@ -533,7 +562,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
}
|
||||
const source = opts.source();
|
||||
// 0.3초 넘으면 카드마다 칸 안 「불러오는 중」(PLAN 30-6 · 31-7) — 일위대가 카드는 뺌:
|
||||
// 몸(Labor_View)은 따로 그려지고 제 「불러오는 중」 이 있음 · 넣으면 덮였다 지워져 빈 카드(40 D1)
|
||||
// 몸(일위대가 상세 카드 갈래)은 따로 읽어 그림(금액 「…」 이 기다림 표시) · 넣으면 덮였다 지워져 빈 카드(40 D1)
|
||||
const stops = cards.filter((c) => c !== laborCard).map((c) => c.shell.loading());
|
||||
const [drawing, ...books] = await Promise.allSettled([
|
||||
source.read("structure", col.id),
|
||||
@@ -582,7 +611,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
destroy: () => {
|
||||
seq += 1;
|
||||
drop();
|
||||
laborView?.destroy();
|
||||
laborEd?.destroy();
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -58,6 +58,13 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 자재 가격 다섯 — 기준단가가 된 칸(43-3) */
|
||||
.m02-labrow__price.is-base-price {
|
||||
color: var(--color-accent);
|
||||
font-weight: 700;
|
||||
box-shadow: inset 0 -2px 0 var(--color-accent);
|
||||
}
|
||||
|
||||
.m02-labrow__applied {
|
||||
display: block;
|
||||
}
|
||||
@@ -229,105 +236,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 한 줄 표(카드용 · 40-1) — 금액 = 수량 반영 값 · 반영이 없으면 기준 값 흐리게 */
|
||||
.m02-labline {
|
||||
width: 100%;
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m02-labline th,
|
||||
.m02-labline td {
|
||||
padding: var(--spacing-4) var(--spacing-6, 6px);
|
||||
}
|
||||
|
||||
.m02-labline th:nth-child(n + 4),
|
||||
.m02-labline .m02-labrow__num {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__name {
|
||||
display: table-cell;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__qty,
|
||||
.m02-labline .m02-labrow__unit {
|
||||
color: var(--color-text-secondary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m02-labline .is-base {
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__sum td {
|
||||
font-weight: 600;
|
||||
border-top: 2px solid var(--color-border);
|
||||
}
|
||||
|
||||
/* 아주 좁으면(카드 3열 · 400px 이하) 결과 단위 칸을 접음 — 수량산출 글에 단위가 같이 보임 */
|
||||
@container m02-labview (max-width: 400px) {
|
||||
.m02-labline th:nth-child(3),
|
||||
.m02-labline .m02-labrow__unit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-labline th,
|
||||
.m02-labline td {
|
||||
padding: var(--spacing-4) 3px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 더 좁으면(카드 320px 안팎 · 340px 이하) 줄마다 두 줄 — 윗줄 항목(3칸) · 수량산출(1칸) · 아랫줄 금액 넷 ·
|
||||
머리 줄도 같은 꼴(글은 그대로) · 가로 넘침 없음 */
|
||||
@container m02-labview (max-width: 340px) {
|
||||
.m02-labline,
|
||||
.m02-labline thead,
|
||||
.m02-labline tbody,
|
||||
.m02-labline tfoot {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.m02-labline tr {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m02-labline th,
|
||||
.m02-labline td {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 2px;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.m02-labline th:first-child,
|
||||
.m02-labline .m02-labrow__name {
|
||||
grid-column: span 3;
|
||||
}
|
||||
|
||||
.m02-labline th:nth-child(2),
|
||||
.m02-labline .m02-labrow__qty {
|
||||
text-align: right;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.m02-labline th:nth-child(3),
|
||||
.m02-labline .m02-labrow__unit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.m02-labline .m02-labrow__num {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 수량산출 칸(40-1) — 위 결과단위 기준 수량 · 아래 지금 수량 · 누르면 손 입력 · 엑셀에서 고르기 */
|
||||
.m02-labrow__qty {
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -28,6 +28,10 @@ export interface CalcMaterial {
|
||||
코드: string;
|
||||
알약?: string | null;
|
||||
기준단가: number | null;
|
||||
/** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */
|
||||
가격?: Record<string, number | null>;
|
||||
/** 기준단가가 된 열 이름 — 없으면 null */
|
||||
기준칸?: string | null;
|
||||
수량?: number | null;
|
||||
수량출처?: QtySource;
|
||||
반영?: number | null;
|
||||
|
||||
@@ -56,6 +56,9 @@ export interface LaborDetailOptions {
|
||||
onChanged: (dirty: boolean) => void;
|
||||
/** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */
|
||||
goArea: GoArea | null;
|
||||
/** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 올리기 · 내리기 · 로직 추가 ·
|
||||
* 정보(단위 · 본뜬) · 다이어그램 없음 · 빼기 · 자재 추가 · 줄 · 수량 모달은 상세와 같음 */
|
||||
card?: boolean;
|
||||
}
|
||||
|
||||
export interface LaborDetailHandle {
|
||||
@@ -105,7 +108,7 @@ export function mountLaborDetail(
|
||||
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
|
||||
|
||||
const drawDg = (): void => {
|
||||
if (!dgBox.open) return;
|
||||
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
|
||||
const data = buildLaborDiagram({
|
||||
rows: cur.담은로직,
|
||||
logics: [...logics.values()],
|
||||
@@ -131,7 +134,9 @@ export function mountLaborDetail(
|
||||
logicTable(
|
||||
{ rows: cur.담은로직, logics, calc, fail, cells: cells() },
|
||||
// 읽기만이면 순서 · 빼기 없음(줄 누름 = 보기 모달은 둠)
|
||||
edit ? { open: openRow, qty: openQty, move, remove: removeRow } : { open: openRow },
|
||||
edit
|
||||
? { open: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow }
|
||||
: { open: openRow },
|
||||
),
|
||||
);
|
||||
matHost.replaceChildren(
|
||||
@@ -355,13 +360,19 @@ export function mountLaborDetail(
|
||||
className: "m01-logic__editor m01-combo m02-labor__edit",
|
||||
attrs: { "data-labor": opt.column },
|
||||
children: [
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [el("div", { className: "m01lab__info", children: [unit.root, origin.root] })],
|
||||
}),
|
||||
section(tc("Logics_Title"), addLogic, logicHost),
|
||||
...(opt.card
|
||||
? []
|
||||
: [
|
||||
el("section", {
|
||||
className: "m01lab__box m01-logic__section",
|
||||
children: [
|
||||
el("div", { className: "m01lab__info", children: [unit.root, origin.root] }),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
|
||||
section(tc("Labor_Mats"), addMat, matHost),
|
||||
dgBox,
|
||||
...(opt.card ? [] : [dgBox]),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀.
|
||||
* 담은 로직 = 차례 · 로직(키 · 이름 + 작은 글 「지역 서울 · 규격 25mm · 수량 B6 = 12.5㎡」 · 「빈 값 n」 붉게) ·
|
||||
* 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · 맨 아래 합계 줄.
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 단위 · 수량 · 기준 단가 · 반영 금액. 금액은 calc 답(없으면 「…」).
|
||||
* 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3).
|
||||
* 금액은 calc 답(없으면 「…」).
|
||||
* 줄 누름 · 순서 단추는 `on` 이 있을 때만(보기 갈래 = 없음).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -170,7 +171,8 @@ export interface LogicTableData {
|
||||
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const { move, remove } = on ?? {};
|
||||
const tools = !!(move && remove);
|
||||
// 빼기만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
|
||||
const tools = !!(move || remove);
|
||||
const lineOf = (key: string): LaborCalcLine | undefined =>
|
||||
data.calc?.줄.find((l) => l.로직 === key);
|
||||
const trs = data.rows.map((row, i) => {
|
||||
@@ -222,14 +224,15 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
qtyCell(line, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
||||
el("td", { className: "m02-labrow__unit", text: unit }),
|
||||
...COSTS.map((c) => moneyCell(line?.기준?.[c], line?.반영?.[c], wait, tc(HEAD[c]))),
|
||||
...(move && remove
|
||||
...(tools
|
||||
? [
|
||||
el("td", {
|
||||
className: "m02-labrow__move",
|
||||
children: [
|
||||
small(tc("Up"), () => move(i, -1)),
|
||||
small(tc("Down"), () => move(i, 1)),
|
||||
small(tc("Remove"), () => remove(i)),
|
||||
...(move
|
||||
? [small(tc("Up"), () => move(i, -1)), small(tc("Down"), () => move(i, 1))]
|
||||
: []),
|
||||
...(remove ? [small(tc("Remove"), () => remove(i))] : []),
|
||||
],
|
||||
}),
|
||||
]
|
||||
@@ -278,7 +281,12 @@ export interface MatTableData {
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** 자재목록 표 — 보기 갈래는 `on` 없이 */
|
||||
/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */
|
||||
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const;
|
||||
|
||||
/** 자재목록 표(43-3 · 로직 줄과 같은 꼴) — 코드 · 이름 · 규격 · 수량산출(위 기준 수량 1 단위 / 아래 지금
|
||||
* 수량) · 단위 · 가격 다섯(빈 것 「—」 · 기준단가가 된 칸 강조) · 금액(위 기준단가 / 아래 반영 = 기준단가 × 수량) ·
|
||||
* 보기 갈래는 `on` 없이 */
|
||||
export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const remove = on?.remove;
|
||||
@@ -286,7 +294,20 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v);
|
||||
const trs = data.mats.map((mat, i) => {
|
||||
const line: LaborCalcMat | undefined = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
const qty = line?.수량 === null || line?.수량 === undefined ? "" : num(line.수량);
|
||||
// 수량산출 칸은 로직 줄 칸을 그대로 씀 — 기준 수량 = 자재 단위 1
|
||||
const qtyLine = { ...line, 배: 1, 분모: mat.단위 ?? "" } as LaborCalcLine;
|
||||
const priceCell = (slot: (typeof PRICE_SLOTS)[number]): HTMLElement => {
|
||||
const base = !!line?.기준칸 && line.기준칸 === slot;
|
||||
return el("td", {
|
||||
className: `m02-labrow__num m02-labrow__price${base ? " is-base-price" : ""}`,
|
||||
text: show(line?.가격?.[slot]),
|
||||
attrs: {
|
||||
"data-label": slot,
|
||||
"data-price-slot": slot,
|
||||
...(base ? { title: tc("Labor_BasePrice") } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
const tr = el("tr", {
|
||||
className: on ? "m02-labrow__row is-open" : "m02-labrow__row",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
@@ -302,29 +323,14 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
],
|
||||
}),
|
||||
el("td", { className: "m02-labrow__spec", text: mat.규격 ?? "" }),
|
||||
qtyCell(qtyLine, data.cells, on?.qty ? () => on.qty?.(i) : null),
|
||||
el("td", {
|
||||
className: "m02-labrow__unit",
|
||||
text: mat.단위 ?? "",
|
||||
attrs: { "data-label": tc("Labor_Unit") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: qty,
|
||||
attrs: {
|
||||
title: qtyText(line, mat.단위 ?? "", data.cells),
|
||||
"data-label": tc("Labor_Qty"),
|
||||
},
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: show(line?.기준단가),
|
||||
attrs: { "data-label": tc("Labor_BasePrice") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__num",
|
||||
text: show(line?.반영),
|
||||
attrs: { "data-label": tc("Labor_AppliedSum") },
|
||||
}),
|
||||
...PRICE_SLOTS.map(priceCell),
|
||||
moneyCell(line?.기준단가, line?.반영, wait, tc("Labor_AppliedSum")),
|
||||
...(remove
|
||||
? [
|
||||
el("td", {
|
||||
@@ -351,125 +357,13 @@ export function matTable(data: MatTableData, on?: Omit<RowHandlers, "move">): HT
|
||||
tc("Labor_Code"),
|
||||
tc("Col_Name"),
|
||||
tc("Labor_Spec"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Labor_Unit"),
|
||||
tc("Labor_Qty"),
|
||||
tc("Labor_BasePrice"),
|
||||
tc("Labor_AppliedSum"),
|
||||
...PRICE_SLOTS,
|
||||
`${tc("Labor_BasePrice")} · ${tc("Labor_AppliedSum")}`,
|
||||
...(remove ? [tc("Col_Move")] : []),
|
||||
];
|
||||
return data.mats.length
|
||||
? table(heads, trs, [])
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("Labor_NoMat") });
|
||||
}
|
||||
|
||||
export interface CompactData {
|
||||
rows: LaborRow[];
|
||||
mats: LaborMat[];
|
||||
logics: Map<string, ItemLogic>;
|
||||
calc: LaborCalc | null;
|
||||
fail?: string | null;
|
||||
cells?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/** 한 줄 표(카드용 · 40-1) — 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계 · 금액 = 수량 반영 값 ·
|
||||
* 반영이 없으면 기준 값 흐리게(`is-base`) · 자재 줄은 같은 표 아래(재료비 · 합계만) · 맨 아래 합계 줄 */
|
||||
export function compactTable(data: CompactData): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const cell = (v: number | null | undefined, base: boolean, label: string): HTMLElement =>
|
||||
el("td", {
|
||||
className: `m02-labrow__num${base ? " is-base" : ""}`,
|
||||
text: wait ? "…" : v === null || v === undefined ? "—" : formatMoney(v),
|
||||
attrs: { "data-label": label, ...(base ? { title: tc("Labor_Base") } : {}) },
|
||||
});
|
||||
// 머리 글 = 사용자 글 그대로(40-1 · 항목 · 수량산출 · 단위 · 노무비 · 재료비 · 경비 · 합계)
|
||||
const heads = [
|
||||
tc("Labor_Item"),
|
||||
tc("Labor_QtyCol"),
|
||||
tc("Labor_Unit"),
|
||||
...COSTS.map((c) => tc(HEAD[c])),
|
||||
];
|
||||
const lines = data.rows.map((row) => {
|
||||
const line = data.calc?.줄.find((l) => l.로직 === row.로직);
|
||||
const money = line?.반영 ?? line?.기준;
|
||||
const base = !line?.반영 && !!line?.기준;
|
||||
const logic = data.logics.get(row.로직);
|
||||
return el("tr", {
|
||||
className: "m02-labrow__row",
|
||||
attrs: { "data-labor-logic": row.로직 },
|
||||
children: [
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
text: logic?.이름 ?? row.로직,
|
||||
attrs: { title: `${row.로직}${line?.까닭 ? ` · ${line.까닭}` : ""}` },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
text: qtyShort(line, line?.분모 ?? "", data.cells),
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__unit", text: logic?.결과단위 ?? "" }),
|
||||
...COSTS.map((c) => cell(money?.[c], base, tc(HEAD[c]))),
|
||||
],
|
||||
});
|
||||
});
|
||||
const mats = data.mats.map((mat) => {
|
||||
const line = data.calc?.자재.find((m) => m.코드 === mat.코드);
|
||||
const has = line?.반영 !== null && line?.반영 !== undefined;
|
||||
const v = has ? line?.반영 : line?.기준단가;
|
||||
return el("tr", {
|
||||
className: "m02-labrow__row is-mat",
|
||||
attrs: { "data-labor-mat": mat.코드 },
|
||||
children: [
|
||||
el("td", {
|
||||
className: "m02-labrow__name",
|
||||
text: mat.이름 ?? mat.코드,
|
||||
attrs: { title: [mat.코드, mat.규격].filter(Boolean).join(" · ") },
|
||||
}),
|
||||
el("td", {
|
||||
className: "m02-labrow__qty",
|
||||
text: qtyShort(line, mat.단위 ?? "", data.cells),
|
||||
attrs: { "data-label": tc("Labor_QtyCol") },
|
||||
}),
|
||||
el("td", { className: "m02-labrow__unit", text: mat.단위 ?? "" }),
|
||||
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.노무비) } }),
|
||||
cell(v, !has && v !== null && v !== undefined, tc(HEAD.재료비)),
|
||||
el("td", { className: "m02-labrow__num", attrs: { "data-label": tc(HEAD.경비) } }),
|
||||
cell(v, !has && v !== null && v !== undefined, tc(HEAD.계)),
|
||||
],
|
||||
});
|
||||
});
|
||||
const applied = data.calc?.반영합;
|
||||
const anyApplied = !!applied && (applied.계 ?? 0) !== 0;
|
||||
const total = anyApplied ? applied : data.calc?.기준합;
|
||||
const foot =
|
||||
data.rows.length + data.mats.length
|
||||
? [
|
||||
el("tr", {
|
||||
className: "m02-labrow__sum",
|
||||
children: [
|
||||
el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }),
|
||||
el("td", { className: "m02-labrow__qty", text: "" }),
|
||||
el("td", { className: "m02-labrow__unit", text: "" }),
|
||||
...COSTS.map((c) => cell(total?.[c], !anyApplied && !!total, tc(HEAD[c]))),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
return data.rows.length + data.mats.length
|
||||
? el("div", {
|
||||
className: "ui-general-block__table-wrap m02-labcompact",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-general-block__table m02-labline",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
|
||||
}),
|
||||
el("tbody", { children: [...lines, ...mats] }),
|
||||
...(foot.length ? [el("tfoot", { children: foot })] : []),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
: el("p", { className: "m02-labrow__muted", text: tc("NoLogic") });
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_View.ts
|
||||
* 일위대가 보기 갈래(39-3 · 고치기 없음) — 그 열 일위대가의 담은 로직 표 + 자재목록 표(기준 · 반영).
|
||||
* 면적산출 상세 오른쪽 패널(sub3) · 구조물 조합 일위대가 카드(sub1)가 씀. 표 모양은 상세와 한 벌(`Labor_Rows`).
|
||||
* 면적산출 상세 오른쪽 패널(sub3)이 씀(구조물 조합 카드는 43-4 부터 `Labor_Detail` 카드 갈래). 표 모양은 상세와 한 벌(`Labor_Rows`).
|
||||
* · `show(층, 열, 수량?)` = 문서 · 로직 입력 목록 · calc 를 받아 그림(최신 응답만)
|
||||
* · `setQty({알약 키: 칸 값}, {알약 키: 칸 주소}?)` = 면적 칸 값이 바뀌면 서버 없이 짝 셈(`Labor_Calc.settle`)으로
|
||||
* 바로 곱함 · 칸 주소를 주면 저장 전 연결(setLink · 알약 끌기)도 「수량 B6 = 20」 으로 보임(안 주면 저장본 연결)
|
||||
* (그 열 면적 알약 값 전부를 줌 — 빠진 키는 「수량 없음」)
|
||||
* · `compact` = 한 줄 표(구조물 조합 일위대가 카드 · 40-1)
|
||||
* · `onRow` = 줄 누름(알약 키 · 이름 · 단위 — 칸 지정 모달 등) · 없으면 누름 없음
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
type LaborDoc,
|
||||
} from "./M02_MasterTemplete_Labor_Api";
|
||||
import { settle } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import { compactTable, logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { logicTable, matTable } from "./M02_MasterTemplete_Labor_Rows";
|
||||
import { tc } from "./M02_MasterTemplete_Labor_Text";
|
||||
import "./M02_MasterTemplete_Labor.css";
|
||||
|
||||
@@ -47,15 +46,7 @@ export interface LaborViewHandle {
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
/** `compact` = 한 줄 표(카드용 · 항목 · 수량산출 · 단위 · 금액 넷 · 자재 줄 같은 표 아래 · 줄 누름 없음) */
|
||||
export interface LaborViewOptions {
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function createLaborView(
|
||||
onRow?: (row: LaborViewRow) => void,
|
||||
opts: LaborViewOptions = {},
|
||||
): LaborViewHandle {
|
||||
export function createLaborView(onRow?: (row: LaborViewRow) => void): LaborViewHandle {
|
||||
const root = el("div", { className: "m02-labview" });
|
||||
const logics = new Map<string, ItemLogic>();
|
||||
let doc: LaborDoc | null = null;
|
||||
@@ -93,10 +84,6 @@ export function createLaborView(
|
||||
unit: mat.단위 ?? "",
|
||||
})?.();
|
||||
};
|
||||
if (opts.compact)
|
||||
return void root.replaceChildren(
|
||||
compactTable({ rows: d.담은로직, mats: d.자재, logics, calc, fail, cells }),
|
||||
);
|
||||
root.replaceChildren(
|
||||
el("h4", { className: "m02-labview__title", text: tc("Logics_Title") }),
|
||||
logicTable(
|
||||
|
||||
@@ -130,8 +130,8 @@ export function createMain(
|
||||
const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") });
|
||||
const reload = createButton({ label: L("M02_Reload"), variant: "ghost" });
|
||||
const save = createButton({ label: L("M02_Save"), variant: "filled" });
|
||||
// 구조물 도면 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채)
|
||||
const back = createButton({ label: L("M02_Back"), variant: "ghost", size: "sm" });
|
||||
// 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님)
|
||||
const back = createButton({ label: L("M02_Back") });
|
||||
const slot = el("div", { className: "m02-main__slot" }); // 도면 양식의 작도 영역 칸이 들어옴
|
||||
const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } });
|
||||
const host = el("div", { className: "m02-main__host" });
|
||||
|
||||
@@ -61,3 +61,22 @@ def test_실패는_카드_안_글과_다시_불러오기():
|
||||
assert "const retry = (): void => void paintLabor(row, seq);" in picker
|
||||
assert "inputs.set(null, why(error), retry);" in picker
|
||||
assert "const stops = [labor.loading(), inputs.loading()];" in picker
|
||||
|
||||
|
||||
def test_일위대가_카드는_상세_카드_갈래():
|
||||
"""43-4 — 일위대가 카드 몸 = Labor_Detail 카드 갈래(단가산출 로직 2행 표 + 자재목록 · 순서 ·
|
||||
로직 추가 · 다이어그램 없음 · 빼기 그대로) · 카드 [저장] = 면적 먼저 → 일위대가 ·
|
||||
한 줄 표 지움."""
|
||||
cards = _text("M02_MasterTemplete_ItemCards.ts")
|
||||
assert "mod.mountLaborDetail(laborHost, got.문서, {" in cards and "card: true," in cards
|
||||
assert 'makeCard("labor", PART_WORD.labor, laborHost, () => saveLabor())' in cards
|
||||
save = cards[cards.index("async function saveLabor()") :]
|
||||
assert save.index("laborEd.saveArea()") < save.index('saveTemplate(layer, "labor"')
|
||||
assert "onChanged: (dirty) => laborCard.saver.mark(dirty && opts.editable())," in cards
|
||||
assert "Labor_View" not in cards # 한 줄 표(40-2) 안 씀
|
||||
detail = _text("M02_MasterTemplete_Labor_Detail.ts")
|
||||
assert "...(opt.card ? {} : { move })" in detail # 순서 단추 없음 · 빼기는 그대로
|
||||
assert "opt.card ? null : addLogic" in detail and "...(opt.card ? [] : [dgBox])," in detail
|
||||
rows = _text("M02_MasterTemplete_Labor_Rows.ts")
|
||||
assert "const tools = !!(move || remove);" in rows and "compactTable" not in rows
|
||||
assert "compact" not in _text("M02_MasterTemplete_Labor_View.ts").split("*/", 1)[1]
|
||||
|
||||
@@ -181,6 +181,9 @@ def test_계산_반영_합은_손셈_원본과_같음(world):
|
||||
(mat,) = got["자재"]
|
||||
assert mat["코드"] == MATERIAL and mat["수량"] == MAT_QTY and mat["기준단가"]
|
||||
assert mat["반영"] == pytest.approx(mat["기준단가"] * MAT_QTY)
|
||||
assert (
|
||||
mat["가격"][mat["기준칸"]] == mat["기준단가"]
|
||||
) # 복사본 단가 묶음으로도 가격 다섯 · 기준칸(43-3)
|
||||
total = hand["반영"]["계"] + area["반영"]["계"] + mat["반영"]
|
||||
assert got["반영합"]["계"] == pytest.approx(total)
|
||||
# 원본 층 문서 계산(마스터 같은 사본)과 같음 — 입력값은 프로젝트에 채운 것
|
||||
|
||||
@@ -98,6 +98,9 @@ def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
|
||||
assert (thousand["수량"], thousand["수량출처"]) == (1, "손")
|
||||
mat = got["자재"][0]
|
||||
assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000)
|
||||
# 43-3 — M01 값 열 다섯(빈 것 null) · 기준단가가 된 열 이름
|
||||
assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "관급"]
|
||||
assert mat["기준칸"] == "물가정보" and mat["가격"]["물가정보"] == 83000
|
||||
nothing = labor.calc(doc, {})["줄"][0]
|
||||
assert (nothing["수량출처"], nothing["반영"]) == ("없음", None)
|
||||
|
||||
|
||||
@@ -238,3 +238,10 @@ def test_상세_화면_돌아가기는_집계표로_그_열_고른_채():
|
||||
body = main.split('back.addEventListener("click"')[1]
|
||||
assert "confirmLeave()" in body and "STRUCTURE_TABLE" in body
|
||||
assert "sheet?.selectColumn(column)" in body
|
||||
|
||||
|
||||
def test_상세_머리_돌아가기는_일반_단추():
|
||||
"""43-5 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] = 공용 기본 단추(filled · 같은 높이 ·
|
||||
알약 · ghost · sm 아님) · 대비는 ui_checks/43-5 시나리오(어두움 8.01)."""
|
||||
main = _text(MAIN)
|
||||
assert 'const back = createButton({ label: L("M02_Back") });' in main
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 43-3 자재 줄 2행 · 가격 다섯 — `orca eval --expression "$(cat 이 파일)"` 한 번에 돎(async 식 · JSON 돌려줌).
|
||||
// 시작 화면 = M02 마스터 템플릿(#/m02-master-template · 시스템 관리자 · 마스터 층).
|
||||
// 하는 일 = 좌측 일위대가 SU-000001 상세 → [자재 추가] → MT004177(시멘트, 전국) 고름 → 줄 수치 읽음 → [빼기]로 되돌림(저장 안 함).
|
||||
// 기대 = heads 에 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · prices = 6,545 · 6,727 · 6,090 · 6,181 · — ·
|
||||
// base = 물가정보(굵게 · 강조) · money.base = 6,090 · qty.base = 「1 포」 · after = 0(빼기 뒤 줄 없음).
|
||||
(async () => {
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 6000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const got = fn();
|
||||
if (got) return got;
|
||||
await wait(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const CODE = "MT004177";
|
||||
const group = [...document.querySelectorAll(".m02-side__group")].find((s) =>
|
||||
s.querySelector(".ui-collapsible__title")?.textContent.trim().startsWith("일위대가"),
|
||||
);
|
||||
if (!group) return JSON.stringify({ error: "좌측 일위대가 없음" });
|
||||
if (group.classList.contains("is-collapsed"))
|
||||
group.querySelector(".ui-collapsible__title").click();
|
||||
const su = [...group.querySelectorAll("button")].find((b) =>
|
||||
b.textContent.startsWith("SU-000001"),
|
||||
);
|
||||
su?.click();
|
||||
const add = await until(() =>
|
||||
[...document.querySelectorAll("button")].find((b) => b.textContent.trim() === "자재 추가"),
|
||||
);
|
||||
if (!add) return JSON.stringify({ error: "자재 추가 단추 없음" });
|
||||
add.click();
|
||||
const box = await until(() => document.querySelector(".m02-pick input"));
|
||||
let item = null;
|
||||
for (let tries = 0; tries < 3 && !item; tries += 1) {
|
||||
// 서버가 막 다시 떴으면 첫 찾기가 늦음 — 다시 침(찾기 칸 글 = 코드)
|
||||
box.value = tries ? `${CODE} ` : CODE;
|
||||
box.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
item = await until(() => {
|
||||
const items = [...document.querySelectorAll(".m02-pick__item")];
|
||||
return items.length === 1 && items[0].dataset.material === CODE ? items[0] : null;
|
||||
}, 4000);
|
||||
}
|
||||
if (!item) return JSON.stringify({ error: "고르기 창에 코드 줄 없음" });
|
||||
item.click();
|
||||
const row = await until(() => {
|
||||
const tr = document.querySelector(`tr[data-labor-mat="${CODE}"]`);
|
||||
return tr && !tr.textContent.includes("…") ? tr : null;
|
||||
}, 8000);
|
||||
if (!row) return JSON.stringify({ error: "자재 줄이 안 섬(계산 대기)" });
|
||||
const table = row.closest("table");
|
||||
const out = {
|
||||
heads: [...table.querySelectorAll("thead th")].map((th) => th.textContent.trim()),
|
||||
prices: [...row.querySelectorAll("[data-price-slot]")].map((td) => td.textContent.trim()),
|
||||
base: [...row.querySelectorAll(".is-base-price")].map((td) => td.dataset.priceSlot),
|
||||
baseWeight: getComputedStyle(row.querySelector(".is-base-price") ?? row).fontWeight,
|
||||
qty: {
|
||||
base: row.querySelector(".m02-labrow__qty .m02-labrow__base")?.textContent.trim(),
|
||||
applied: row.querySelector(".m02-labrow__qty .m02-labrow__applied")?.textContent.trim(),
|
||||
},
|
||||
money: {
|
||||
base: row.querySelector(".m02-labrow__money .m02-labrow__base")?.textContent.trim(),
|
||||
applied: row.querySelector(".m02-labrow__money .m02-labrow__applied")?.textContent.trim(),
|
||||
},
|
||||
};
|
||||
// 되돌림 — 그 줄 [빼기](저장 안 함)
|
||||
[...row.querySelectorAll("button")].find((b) => b.textContent.trim() === "빼기")?.click();
|
||||
await wait(400);
|
||||
out.after = document.querySelectorAll(`tr[data-labor-mat="${CODE}"]`).length;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
/* 43-4 — 구조물 조합 일위대가 카드 = 일위대가 상세 카드 갈래(단가산출 로직 2행 표 + 자재목록)
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 ·
|
||||
* 나무에 item1(일위대가 담은 로직 있는 열)이 있음
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌 · 20초 안 — 길면 ORCA 가 끊음)
|
||||
* 기대 수치:
|
||||
* - sections = ["단가산출 로직", "자재목록"] · diagram = 0 · info = 0(단위 · 본뜬 칸 없음)
|
||||
* - addLogic = 0 · up = down = 0 · remove = 로직 줄 수 · addMat = 1 · rows = 담은 로직 수(item1 = 14)
|
||||
* - money2 = true(금액 칸이 두 줄 — 위 기준 · 아래 반영)
|
||||
* - scroll.overflowX = "auto" · scroll.wide = 참(item1 표 ≈ 870px > 카드 ≈ 310px · 표 감싸개 안 가로 스크롤)
|
||||
* - rowModal = 「넣을 값 — …」 · qtyModal = 모달 뜸(수량 · 43-1) · 둘 다 닫기 뒤 dirty = false
|
||||
* - 빼기 하나 → dirty = true · saveEnabled = true · 다른 항목(item3) → 버림 물음 「확인」 → item1 다시(되돌림)
|
||||
* → rowsAfter = rows · dirtyAfter = false
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const until = async (fn, ms = 6000) => {
|
||||
for (let t = 0; t < ms; t += 150) {
|
||||
const v = fn();
|
||||
if (v) return v;
|
||||
await w(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const vis = (b) => b && !b.hidden && b.offsetParent !== null;
|
||||
const pick = async () => {
|
||||
document.querySelector('[data-node="i|item1"]').click();
|
||||
return until(() => {
|
||||
const ed = document.querySelector(".m02-struct__labor .m02-labor__edit");
|
||||
return ed && ed.querySelector("tbody tr[data-labor-logic]") ? ed : null;
|
||||
});
|
||||
};
|
||||
const ed = await pick();
|
||||
if (!ed) return JSON.stringify({ error: "카드 몸 없음" });
|
||||
const card = ed.closest(".m02-struct > *");
|
||||
const btn = (t) =>
|
||||
[...card.querySelectorAll("button")].filter((b) => vis(b) && b.textContent === t);
|
||||
const save = [...card.querySelectorAll(".ui-collapsible__title button")].find(
|
||||
(b) => b.textContent === "저장",
|
||||
);
|
||||
const rows = () => ed.querySelectorAll("tbody tr[data-labor-logic]").length;
|
||||
const money = ed.querySelector("tbody tr[data-labor-logic] td:nth-child(5)");
|
||||
const out = {
|
||||
sections: [...ed.querySelectorAll(".m01-logic__section-head h3")].map((h) => h.textContent),
|
||||
diagram: ed.querySelectorAll(".m02-labor__dg").length,
|
||||
info: ed.querySelectorAll("[data-labor-field]").length,
|
||||
addLogic: btn("로직 추가").length,
|
||||
up: btn("올리기").length,
|
||||
down: btn("내리기").length,
|
||||
remove: btn("빼기").length,
|
||||
addMat: btn("자재 추가").length,
|
||||
rows: rows(),
|
||||
money2: !!money && money.children.length >= 2,
|
||||
// 표마다 공용 감싸개(ui-general-block__table-wrap)가 가로 스크롤 — 카드보다 넓으면 그 안에서
|
||||
scroll: (() => {
|
||||
const wrap = ed.querySelector("table")?.parentElement;
|
||||
return wrap
|
||||
? {
|
||||
overflowX: getComputedStyle(wrap).overflowX,
|
||||
wide: wrap.scrollWidth > wrap.clientWidth,
|
||||
sw: wrap.scrollWidth,
|
||||
cw: wrap.clientWidth,
|
||||
}
|
||||
: null;
|
||||
})(),
|
||||
};
|
||||
// 줄 누름 = 넣을 값 모달 · 수량산출 칸 = 수량 모달 — 닫기만(값 안 바꿈)
|
||||
const modal = async (open) => {
|
||||
open();
|
||||
const m = await until(() => document.querySelector(".ui-modal"), 2000);
|
||||
const t = m?.querySelector("h3")?.textContent ?? null;
|
||||
[...(m?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "닫기")?.click();
|
||||
await until(() => !document.querySelector(".ui-modal"), 1500);
|
||||
return t;
|
||||
};
|
||||
const first = () => ed.querySelector("tbody tr[data-labor-logic]");
|
||||
out.rowModal = await modal(() => first().querySelector(".m02-labrow__name").click());
|
||||
out.qtyModal = await modal(() =>
|
||||
(
|
||||
first().querySelector("[data-labor-qty-open]") ?? first().querySelector(".m02-labrow__qty")
|
||||
).click(),
|
||||
);
|
||||
out.dirtyAfterModals = !save.disabled;
|
||||
// 빼기 하나 → 고침 표시 · 다른 항목으로 갔다 버림 확인 → item1 다시(서버 판으로 되돌림)
|
||||
btn("빼기").pop().click();
|
||||
await w(300);
|
||||
out.dirty = card.classList.contains("is-dirty") || !save.disabled;
|
||||
out.saveEnabled = !save.disabled;
|
||||
document.querySelector('[data-node="i|item3"]').click();
|
||||
const ask = await until(() => document.querySelector(".ui-confirm"), 2000);
|
||||
[...(ask?.querySelectorAll("button") ?? [])].find((b) => b.textContent === "확인")?.click();
|
||||
await w(600);
|
||||
const again = await pick();
|
||||
out.rowsAfter = again ? again.querySelectorAll("tbody tr[data-labor-logic]").length : null;
|
||||
out.dirtyAfter = !save.disabled;
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,96 @@
|
||||
/* 43-5 · E1 — 도면 · 일위대가 · 면적산출 상세 머리 [돌아가기] 꼴 · 머리 단추 글자 대비(밝음 · 어두움)
|
||||
* 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자
|
||||
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
|
||||
* 기대 수치:
|
||||
* - 세 상세 모두 back.cls = "ui-btn ui-btn--filled"(ghost · sm 없음) · back.h = 머리 다른 단추 높이(±1px)
|
||||
* - 밝음 · 어두움(앱 ☾ 단추) 모두 머리 보이는 단추 대비 ≥ 4.5
|
||||
* - 끝나면 처음 테마로 되돌림(theme.end = theme.start)
|
||||
* ⚠ 단추 color 전환(120ms)이 ORCA 탭에선 0ms 에 멈춰 옛 색이 읽힘(1.11 착오 · E1) — 재는 동안 전환을 끔
|
||||
*/
|
||||
(async () => {
|
||||
const w = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const rgb = (s) => (s.match(/[\d.]+/g) || []).map(Number);
|
||||
const lum = ([r, g, b]) => {
|
||||
const f = (v) => ((v /= 255) <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
/** 실제로 보이는 바탕 — 투명이면 조상으로 */
|
||||
const bgOf = (el) => {
|
||||
for (let e = el; e; e = e.parentElement) {
|
||||
const c = rgb(getComputedStyle(e).backgroundColor);
|
||||
if (c.length >= 3 && (c.length < 4 || c[3] > 0.5)) return c.slice(0, 3);
|
||||
}
|
||||
return [255, 255, 255];
|
||||
};
|
||||
const ratio = (el) => {
|
||||
const a = lum(rgb(getComputedStyle(el).color).slice(0, 3));
|
||||
const b = lum(bgOf(el));
|
||||
return +((Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05)).toFixed(2);
|
||||
};
|
||||
const theme = () => document.documentElement.getAttribute("data-theme") || "auto";
|
||||
const moon = () =>
|
||||
[...document.querySelectorAll("button")].find((b) => /^[☾☀]$/.test(b.textContent.trim()));
|
||||
const headButtons = () =>
|
||||
[...document.querySelectorAll(".m02-main__head button")].filter(
|
||||
(b) => !b.hidden && b.offsetParent,
|
||||
);
|
||||
const openRow = async (sectionTitle, prefix) => {
|
||||
const sec = [...document.querySelectorAll(".m02-side__summary > section")].find(
|
||||
(s) => s.querySelector("h3").textContent.trim() === sectionTitle,
|
||||
);
|
||||
sec.classList.remove("is-collapsed");
|
||||
[...sec.querySelectorAll("button")].find((b) => b.textContent.startsWith(prefix)).click();
|
||||
await w(5000);
|
||||
};
|
||||
const measure = () => {
|
||||
const back = [...document.querySelectorAll(".m02-main__head button")].find(
|
||||
(b) => b.textContent.trim() === "돌아가기",
|
||||
);
|
||||
const others = headButtons().filter((b) => b !== back);
|
||||
return {
|
||||
title: document.querySelector(".m02-main__title")?.textContent,
|
||||
back: back && {
|
||||
cls: back.className,
|
||||
h: Math.round(back.getBoundingClientRect().height),
|
||||
radius: getComputedStyle(back).borderRadius,
|
||||
},
|
||||
otherH: others.map((b) => Math.round(b.getBoundingClientRect().height)),
|
||||
contrast: Object.fromEntries(
|
||||
headButtons().map((b) => [b.textContent.trim() || b.title, ratio(b)]),
|
||||
),
|
||||
};
|
||||
};
|
||||
const still = document.createElement("style");
|
||||
still.textContent = "*,*::before,*::after{transition:none!important}";
|
||||
document.head.append(still);
|
||||
const start = theme();
|
||||
const out = { theme: { start }, pages: {} };
|
||||
for (const [name, sec, prefix] of [
|
||||
["도면", "구조물 도면", "SD-000001"],
|
||||
["일위대가", "일위대가", "SU-000001"],
|
||||
["면적산출", "면적산출", "SA-000001"],
|
||||
]) {
|
||||
await openRow(sec, prefix);
|
||||
const light = measure();
|
||||
moon()?.click();
|
||||
await w(400);
|
||||
const dark = { theme: theme(), ...measure() };
|
||||
moon()?.click();
|
||||
await w(400);
|
||||
out.pages[name] = { light, dark };
|
||||
}
|
||||
if (theme() !== start) moon()?.click();
|
||||
out.theme.end = theme();
|
||||
still.remove();
|
||||
const all = Object.values(out.pages).flatMap((p) => [
|
||||
...Object.values(p.light.contrast),
|
||||
...Object.values(p.dark.contrast),
|
||||
]);
|
||||
out.minContrast = Math.min(...all);
|
||||
out.backOk = Object.values(out.pages).every(
|
||||
(p) =>
|
||||
p.light.back?.cls === "ui-btn ui-btn--filled" &&
|
||||
p.light.otherH.every((h) => Math.abs(h - p.light.back.h) <= 1),
|
||||
);
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,67 @@
|
||||
/* 43-6 도면 표 선 색(흠 E2) — orca eval 한 번에 도는 시나리오(읽기만 · 저장 안 함).
|
||||
* 시작 화면: M02 · 시스템 관리자 · 구조물 도면 상세(마스터 item1 · CAD 열림 · 보기 전용 아님).
|
||||
* 하는 일: 면적산출 범위 → 표 변환(POST …/area/item1/table · 답에 lineColor 없음) → CAD 에 place-table →
|
||||
* 커서 옮김 → 누름(놓기) → save-request 로 CAD 도면 JSON 받음 → Ctrl+Z 로 되돌림.
|
||||
* 기대: 변환 답 lineColor 없음(answerHasColor false) · 놓인 표 1 · 저장 JSON 표 lineColor = "#fff" ·
|
||||
* lineWidth 1 · id 있음 · 되돌린 뒤 표 수 = 시작 수.
|
||||
*/
|
||||
(async () => {
|
||||
const W = (t) => new Promise((r) => setTimeout(r, t));
|
||||
const f = document.querySelector("iframe");
|
||||
const w = f.contentWindow;
|
||||
const cad = w.__aisloCad;
|
||||
const before = cad.entities().filter((e) => e.type === "Table").length;
|
||||
const area = await (
|
||||
await fetch("/api/m02/layers/system/templates/area/item1", { credentials: "include" })
|
||||
).json();
|
||||
const sheet = area.문서.시트[0].id;
|
||||
const got = await (
|
||||
await fetch("/api/m02/layers/system/area/item1/table", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ 시트: sheet, 범위: "A1:B2", 글자크기: 2.5 }),
|
||||
})
|
||||
).json();
|
||||
const entity = got.표;
|
||||
w.postMessage({ type: "aislo:b08:place-table", entity, link: "T43" }, location.origin);
|
||||
await W(600);
|
||||
const c = f.contentDocument.querySelector("canvas");
|
||||
const r = c.getBoundingClientRect();
|
||||
const at = { bubbles: true, button: 0, clientX: r.left + 320, clientY: r.top + 260 };
|
||||
c.dispatchEvent(new MouseEvent("mousemove", at));
|
||||
await W(window.__hold ?? 300); // 사진 찍을 때는 __hold 를 늘림
|
||||
c.dispatchEvent(new MouseEvent("mousedown", at));
|
||||
c.dispatchEvent(new MouseEvent("mouseup", at));
|
||||
await W(500);
|
||||
const placed = cad.entities().filter((e) => e.type === "Table").length - before;
|
||||
let saved = null;
|
||||
const take = (e) => {
|
||||
if (e.data && e.data.type === "aislo:b08:save-response") saved = e.data.drawing;
|
||||
};
|
||||
window.addEventListener("message", take);
|
||||
w.postMessage({ type: "aislo:b08:save-request" }, location.origin);
|
||||
await W(1000);
|
||||
window.removeEventListener("message", take);
|
||||
const table = (saved?.entities ?? []).find(
|
||||
(e) => e.type === "Table" && e.shapeData?.style?.연결 === "T43",
|
||||
);
|
||||
f.contentDocument.body.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "z",
|
||||
code: "KeyZ",
|
||||
ctrlKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
await W(400);
|
||||
return JSON.stringify({
|
||||
answerHasColor: "lineColor" in entity,
|
||||
placed,
|
||||
lineColor: table?.lineColor ?? null,
|
||||
lineWidth: table?.lineWidth ?? null,
|
||||
hasId: !!table?.id,
|
||||
afterUndo: cad.entities().filter((e) => e.type === "Table").length === before,
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user