Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_4' into sub_laptop_2

This commit is contained in:
2026-11-15 14:13:45 +09:00
19 changed files with 600 additions and 352 deletions
+14
View File
@@ -29,6 +29,20 @@ export function nameMachines(list: string[], redraw: () => void): void {
});
}
let asked = false;
/** 글 값이 기계 원문번호면 「이름 규격 (원문번호)」(838-3 · 요구 859) — 이름이 아직 없으면 한 번 받아 `redraw` · 아니면 null */
export function machineText(x: unknown, redraw: () => void): string | null {
if (typeof x !== "string" || !MACHINE.test(x)) return null;
if (!(x in names) && !asked) {
asked = true;
void cfMachineNames().then((got) => {
names = { ...names, ...got };
redraw();
});
}
return optionText(x);
}
export function buildOptions(
doc: FormulaDoc | null,
given: Record<string, unknown>,
+34
View File
@@ -473,3 +473,37 @@
grid-template-columns: minmax(0, 1fr);
}
}
/* 836-1 기계 제어 구역 — 기계 줄 아래 접는 구역(값 칸 · 조종원 인력 줄 · 잡품 재료 줄) · 줄 꼴 = 단가산출 줄과 닮음 */
.lstk-mctl {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-block-start: var(--spacing-4);
padding: var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
}
.lstk-mctl > .lstk-frow {
display: grid;
grid-template-columns: 7em auto 11em auto auto;
justify-content: start;
}
.lstk-mctl > .lstk-frow > .lstk-sym {
max-width: none;
}
.lstk-mctl .lstk-f-s {
width: auto;
}
.lstk-mctl .lstk-line.k-ctl > .lstk-f-s {
flex: 0 1 9em;
min-width: 6em;
}
.lstk-mctl .lstk-line.k-ctl > :last-child {
margin-inline-start: auto;
}
+35 -15
View File
@@ -20,7 +20,7 @@ import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_ut
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
import { coverOf } from "../common_util/common_util_formula_doc_cover";
import {
dropSyms,
docOf,
@@ -38,12 +38,7 @@ import {
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { askFileWide, materialMark, matEntry } from "./line_stack_material";
import {
draftsOf,
machineNow,
pruneDrafts,
putDraft,
} from "../common_util/common_util_line_stack_draft";
import { draftsOf, machineNow, pruneDrafts } from "../common_util/common_util_line_stack_draft";
import {
addPicked,
type Picked,
@@ -57,6 +52,7 @@ import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_U
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { openMachineWindow, type MachineCtx } from "./line_stack_machine";
import { buildMachineCtl, machineOfLine } from "./line_stack_machine_ctl";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock, sourceTag } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
@@ -78,10 +74,10 @@ export interface EditHooks {
take?: StackTake;
/** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */
before?: ReadonlyMap<string, unknown>;
/** 827-1 기계 줄 = 기계 창(조회 · 바꾸기 · 고쳐 쓰기) — 층 · 권한 · 사본 줄 */
/** 827-1 기계 줄 = 기계 창(찾기 · 바꾸기 · 836-1) — 층 · 사본 줄 */
machine?: MachineCtx;
/** 827-1 초안 기계 셈 기록 → 계산기 캐시(브라우저 즉시 셈 · 서버 미리 셈과 같은 값) */
prime?: (record: DocRecord) => void;
/** 836-1 기계 제어 구역 — 덮음 없이 그 기계 식 셈(원본 = 기본값) */
calc?: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
}
const field = (
@@ -192,10 +188,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
now: machineNow(d, link as { 갈래?: string; 입력?: Record<string, string> }),
query,
pick: swap,
draft: (item, p, record) => {
h.prime?.(record);
h.change((doc) => putDraft(doc, i, own, item, p));
},
})
: openStackPick(kind, swap, undefined, query),
});
@@ -203,6 +195,24 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
pick.dataset.repick = String(i);
head.push(pick);
if (kind === "기계" && h.calc) {
// 836-1 기계 제어 구역 접기 · 고친 기계 표지
const on = h.open.has(`mctl:${i}`);
const t = createIconButton({
icon: on ? "chevronDown" : "chevronRight",
title: lt("Ctl_Open"),
size: 24,
onClick: () => {
if (on) h.open.delete(`mctl:${i}`);
else h.open.add(`mctl:${i}`);
h.change(() => undefined);
},
});
t.dataset.mctlToggle = String(i);
head.push(t);
const m = machineOfLine(h.answer, link);
if (m && coverOf(d)[m]) head.push(createBadge(lt("Ctl_Changed"), "info", { size: 18 }));
}
const mark = machineMark(found, link);
if (mark) head.push(mark);
const matMark = kind === "재료" ? materialMark(matEntry(d, own), link) : null; // 827-3 판정 · 단서
@@ -310,7 +320,9 @@ function qtyBox(
);
f.dataset.qty = String(i);
const val = v ? (h.answer.값 ?? {})[text] : undefined;
const tag = v ? sourceTag(h, text, v.연결) : null;
// 838-3 공식 · 표 값 묶음에 이미 보이는 기호 = 줄 쪽 표 칩 걷음(묶음에서 고름) · 묶음에 없으면 줄에서 고름
const inBlock = h.items.some((it) => it.줄.some((l) => l.꼴 === "공식" && l.기호.includes(text)));
const tag = v && !inBlock ? sourceTag(h, text, v.연결) : null;
if (tag && isNum(val))
tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`;
return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] });
@@ -490,6 +502,14 @@ export function buildEdit(h: EditHooks): HTMLElement {
: [lineGrip, ...body, del],
});
card.dataset.loc = `${i}.${j}`;
if (line.꼴 === "금액" && h.calc && h.open.has(`mctl:${line.i}`)) {
const own = ownedOf(h.doc).get(line.i);
const zone = buildMachineCtl(
{ ...h, calc: h.calc, change: (fn) => h.change((d) => fn(d)), readOnly: !h.machine?.scope }, // prettier-ignore
h.doc.변수?.find((x) => varId(x) === own)?.연결,
);
if (zone) card.append(zone);
}
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
attachDrag(lineGrip, {
what: "line",
+36 -220
View File
@@ -1,48 +1,38 @@
/* =============================================================================
* line_stack_machine.ts
* 줄 쌓기 기계 창(PLAN 827-1 · 요구 810 · 811) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움.
* 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당)
* 오른쪽 = 공통 기계 양식(`ui_template_machine_form` · 읽기 전용) — 지금 줄 = 이 단가산출 사본에 박힌 값
* (원본 지금 값과 다르면 「새 값 있음」) · 고른 줄 = 그 기계 지금 값
* [이 기계로 바꾸기] = `pick`(부른 쪽 `repick` · 가져온 식 823-1 규칙 그대로)
* [고쳐 쓰기](개인 층 · 쓰기 권한 · `ctx.canEdit`) = 양식 편집 → [확인] = 서버 확인(쓰기 없음) → `draft`
* (부른 쪽이 문서 칸 `내기계초안` 에 · 디스크는 단가산출 [저장] 때 서버) · 그 밖 = 까닭 한 줄
* M01 기계에서 시작 = 새 내 기계(본뜬 = 그 키) · 내 기계 = 그 기계 고침 · 초안 = 그 초안 고침 · 즉시 저장 없음
* 줄 쌓기 기계 창(PLAN 827-1 · 836-1 · 요구 810 · 846 · 853) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움 = 찾기 · 바꾸기만
* 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 옛 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당)
* 오른쪽 = 고른 기계 이름 · 시간당 · 사본 표지(이 단가산출에 복사된 값 · 원본 새 값) · [이 기계로 바꾸기] = `pick`
* 기계 내부 값 고침 = 단가산출 왼쪽 기계 제어 구역(`line_stack_machine_ctl` · 836-1) — 이 창의 양식 · [고쳐 쓰기] 걷음
* (단가산출 안 고침은 내 기계를 만들지 않음 · 내 기계 목록 · 개인관리 화면은 그대로)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createDataTable } from "@ui/ui_template_data_table";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createMachineForm, HOURLY_KEY, type MachineValues } from "@ui/ui_template_machine_form";
import { createInputField, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { isDraftNo, newTag, type DraftItem } from "../common_util/common_util_line_stack_draft";
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
import { isDraftNo, type DraftItem } from "../common_util/common_util_line_stack_draft";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import {
checkDraft,
fetchMyMachineLists,
listMyMachines,
machineArg,
machineRows,
readMyMachine,
type MachineBrief,
type MyHourly,
type MyMachineRow,
} from "../M02_MasterTemplete/M02_MyMachine_Api";
import { bodyOfForm, hourlyText, valuesOf } from "../M02_MasterTemplete/M02_MyMachine_Form";
import { hourlyText } from "../M02_MasterTemplete/M02_MyMachine_Form";
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
import { lt } from "./line_stack_text";
/** 사본 줄(서버 `copy_row`) — 키 · 값 · 방식(내 기계) · 시간당(직접) */
/** 사본 줄(서버 `copy_row`) — 키 · 이름 · 규격 */
interface CopyRowData {
키: string;
원문번호?: string;
이름?: string;
규격?: string;
값?: MachineValues;
방식?: string;
시간당?: Record<string, number>;
}
export interface CopyAnswer {
사본: CopyRowData | null;
@@ -53,11 +43,7 @@ export interface CopyAnswer {
export interface MachineCtx {
/** 층 · 주인 — null = M01 단가산출 기초(M01 기계만) */
scope: { layer: Layer; owner: string | null } | null;
/** [고쳐 쓰기] 됨(개인 층 SK · 쓰기 권한) */
canEdit: boolean;
/** 못 고칠 까닭(M01 · 회사 · 마스터 층) */
why: string;
/** 이 SK 키(쓰는 곳 셈에서 뺌) */
/** 이 SK 키 */
me?: string;
/** 이 SK 사본에 박힌 기계 줄(키 있는 M02 SK) */
copyRow?: (no: string) => Promise<CopyAnswer>;
@@ -71,8 +57,6 @@ export interface MachineWindowOptions {
query: string;
/** 없으면 보기만(옵션 가로 조회) */
pick?: (p: Picked) => void;
/** 고쳐 쓰기 [확인] — 초안 · 줄 연결 · 그 식 셈 기록(계산기 캐시 · 즉시 셈) */
draft?: (d: DraftItem, p: Picked, record: DocRecord) => void;
}
type Kind = "now" | "draft" | "mine" | "m01";
@@ -90,45 +74,15 @@ interface Cand {
key?: string;
}
interface Shown {
values: MachineValues;
name: string;
spec: string;
lines: HTMLElement[];
logic?: string | null;
/** [고쳐 쓰기] 대상 */
target: { 표지?: string; 키?: string; 본뜬?: string };
/** 내 기계 = 쓰는 곳 셈 키 */
mineKey?: string;
}
const kindOf = (no: string): Kind =>
isDraftNo(no) ? "draft" : no.startsWith("개인 ") ? "mine" : "m01";
/** 사본 · 원본 줄 → 양식 값 — 직접 = 시간당 · 바로(셈 꼴) = 계수 셋 · 빈 연료 · 조종원 0 뺌 */
function formValues(row: CopyRowData): MachineValues {
if (row.방식 === "직접") {
const v = row.시간당 ?? row.값 ?? {};
return { [HOURLY_KEY]: { 노무비: v.노무비, 재료비: v.재료비, 경비: v.경비 } };
}
const v: MachineValues = { ...(row.값 ?? {}) };
if (row.방식 === "바로") {
for (const k of ["상각비계수", "정비비계수", "관리비계수"]) delete v[k];
for (const k of ["주연료", "잡재료율", "조종원"]) if (v[k] === 0) delete v[k];
for (const k of ["주연료종류", "조종원직종"]) if (v[k] === "") delete v[k];
}
return v;
}
/** 내 기계 목록 줄 → 양식 값(직접 = 서버 시간당 노 · 재 · 경) */
const mineValues = (m: MyMachineRow): MachineValues =>
valuesOf({
방식: m.방식,
값: m.값,
시간당: {
노무비: m.시간당.노무비 ?? 0,
재료비: m.시간당.재료비 ?? 0,
경비: m.시간당.경비 ?? 0,
},
});
const hourOf = (h: MyHourly | undefined): number | null => (h?.ok ? (h.계 ?? null) : null);
const same = (a: Cand | undefined, b: Cand | null): boolean =>
!!a && !!b && a.kind === b.kind && a.no === b.no && a.key === b.key;
@@ -142,8 +96,6 @@ export function openMachineWindow(o: MachineWindowOptions): void {
const briefs = new Map<string, MachineBrief>();
let cands: Cand[] = [];
let sel: Cand | null = null;
let editing = false;
let formDirty = (): boolean => false;
let seq = 0;
let showSeq = 0;
let timer = 0;
@@ -264,7 +216,7 @@ export function openMachineWindow(o: MachineWindowOptions): void {
return got;
};
/** 고른 줄 → 양식 값 · 글 · 바꿀 연결 · 고칠 대상 */
/** 고른 줄 → 이름 · 글(시간당 · 사본 표지) · 바꿀 식 */
const resolve = async (c: Cand): Promise<Shown> => {
const k = c.kind === "now" ? kindOf(c.no) : c.kind;
const lines: HTMLElement[] = [];
@@ -274,24 +226,7 @@ export function openMachineWindow(o: MachineWindowOptions): void {
lines.push(createText(mt("Win_Draft"), "muted"));
if (d.시간당)
lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(d.시간당 as MyHourly)}`));
const target = {
표지: d.표지,
...(d.키 ? { 키: d.키 } : {}),
...(d.본뜬 ? { 본뜬: d.본뜬 } : {}),
};
return {
values: valuesOf({
방식: d.줄.방식 as never,
값: (d.줄.값 ?? {}) as MachineValues,
시간당: d.줄.시간당 as never,
}),
name: d.줄.이름,
spec: d.줄.규격,
lines,
logic: d.시간당?.로직,
target,
mineKey: d.키,
};
return { name: d.줄.이름, spec: d.줄.규격, lines, logic: d.시간당?.로직 };
}
// 지금 줄 = 이 단가산출 사본 값(있으면)
const copy = c.kind === "now" && ctx.copyRow ? await ctx.copyRow(c.no).catch(() => null) : null;
@@ -302,29 +237,22 @@ export function openMachineWindow(o: MachineWindowOptions): void {
}
if (k === "mine") {
const m = c.mine ?? mines.find((r) => r.원문번호 === c.no);
const key = m?.키 ?? c.no.replace(/^개인 /, "");
const was = o.drafts.find((d) => d.키 === key); // 이미 고친 초안 = 그 초안을 다시 고침
if (!row && m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`));
if (m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`));
return {
values: row ? formValues(row) : m ? mineValues(m) : {},
name: row?.이름 ?? m?.이름 ?? c.이름,
spec: row?.규격 ?? m?.규격 ?? c.규격,
lines,
logic: m?.시간당.로직,
target: { 키: key, ...(was ? { 표지: was.표지 } : {}) },
mineKey: key,
};
}
const b = await briefOf(c.no, c.key);
if (!row && b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`));
if (b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`));
if (!row && !b) throw new Error(mt("Hourly_None"));
return {
values: row ? formValues(row) : { ...(b?.값 ?? {}) },
name: row?.이름 ?? b?.이름 ?? c.이름,
spec: row?.규격 ?? b?.규격 ?? c.규격,
lines,
logic: b?.시간당.ok ? b.시간당.로직 : null,
target: { 본뜬: row?.키 ?? b?.키 },
};
};
@@ -335,19 +263,17 @@ export function openMachineWindow(o: MachineWindowOptions): void {
};
const choose = async (c: Cand): Promise<void> => {
if (editing && formDirty() && !(await showConfirmDialog(mt("Leave_Ask")))) return;
editing = false;
sel = c;
table.setRows(cands.map((x) => ({ 품명: x.이름, 규격: x.규격, 금액: x.계, 구분: x.구분 })));
await show(c);
};
async function show(c: Cand, edit = false, blank = false): Promise<void> {
async function show(c: Cand): Promise<void> {
const mine = ++showSeq;
right.replaceChildren(createText(mt("Server_Hourly"), "muted"));
let s: Shown;
try {
s = blank ? { values: {}, name: "", spec: "", lines: [], target: {} } : await resolve(c);
s = await resolve(c);
} catch (error) {
if (mine === showSeq)
right.replaceChildren(
@@ -355,151 +281,41 @@ export function openMachineWindow(o: MachineWindowOptions): void {
);
return;
}
const lists = await fetchMyMachineLists();
if (mine !== showSeq) return;
editing = edit;
const notice = el("div", { className: "lstk-mwin__notice" });
const form = createMachineForm({
name: s.name,
spec: s.spec,
values: s.values,
coefMode: "direct",
allowHourly: true,
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
readOnly: !edit,
onChange: () => notice.replaceChildren(),
});
formDirty = () => edit && form.state().changed;
const bar: HTMLElement[] = [];
if (!edit) {
const p = pickedOf(c, s);
if (o.pick && c.kind !== "now") {
const b = createButton({
label: mt("Win_Swap"),
variant: "filled",
size: 28,
disabled: !p,
onClick: () => {
if (!p) return;
close();
o.pick!(p);
},
});
b.dataset.mwinSwap = "";
if (!p) b.title = mt("Pick_Direct");
bar.push(b);
}
if (o.draft && ctx.canEdit) {
const b = createButton({
label: mt("Win_Edit"),
variant: c.kind === "now" ? "filled" : "ghost",
size: 28,
iconStart: "pencil",
onClick: () => void show(c, true),
});
b.dataset.mwinEdit = "";
bar.push(b);
} else if (o.draft && ctx.why) bar.push(createText(ctx.why, "muted"));
} else {
const ok = createButton({ label: mt("Win_Ok"), variant: "filled", size: 28 });
ok.dataset.mwinOk = "";
ok.addEventListener("click", () => void confirm(form.state(), s, notice, ok));
bar.push(
ok,
createButton({
label: mt("Win_Cancel"),
variant: "ghost",
size: 28,
onClick: () => void (blank ? choose(c) : show(c)),
}),
);
}
const extra: HTMLElement[] = [];
if (edit) extra.push(createText(mt("Win_Edit_Note"), "muted"));
if (edit && s.target.키) {
const uses = el("div");
extra.push(uses);
void readMyMachine(owner, s.target.키)
.then((one) => {
const n = (one.쓰는곳 ?? []).filter((u) => u.키 !== ctx.me).length;
uses.replaceChildren(createText(mt("Win_Uses").replace("{value}", String(n)), "warning"));
})
.catch(() => undefined);
const p = pickedOf(c, s);
if (o.pick && c.kind !== "now") {
const b = createButton({
label: mt("Win_Swap"),
variant: "filled",
size: 28,
disabled: !p,
onClick: () => {
if (!p) return;
close();
o.pick!(p);
},
});
b.dataset.mwinSwap = "";
if (!p) b.title = mt("Pick_Direct");
bar.push(b);
}
right.replaceChildren(
el("div", {
className: "ui-toolbar",
children: [el("strong", { text: `${s.name} ${s.spec}`.trim() || mt("New") }), ...bar],
children: [el("strong", { text: `${s.name} ${s.spec}`.trim() }), ...bar],
}),
...s.lines,
...extra,
notice,
form.root,
createText(lt("Win_Ctl_Note"), "muted"),
);
right.dataset.mwinMode = edit ? "edit" : "view";
right.dataset.mwinMode = "view";
}
/** [확인] — 서버 확인(쓰기 없음) → 초안 · 줄 연결 */
async function confirm(
state: ReturnType<ReturnType<typeof createMachineForm>["state"]>,
s: Shown,
notice: HTMLElement,
btn: HTMLButtonElement,
): Promise<void> {
const { body, errors } = bodyOfForm(state);
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (!body) return;
btn.disabled = true;
try {
const tag = s.target.표지 ?? newTag();
const got = await checkDraft(owner, body, { ...s.target, 표지: tag });
if (!got.시간당.ok || !got.시간당.로직)
throw new Error(hourlyText(got.시간당) || mt("Pick_Direct"));
const item: DraftItem = {
표지: tag,
...(s.target.키 ? { 키: s.target.키 } : {}),
...(!s.target.키 && s.target.본뜬 ? { 본뜬: s.target.본뜬 } : {}),
줄: body,
시간당: got.시간당,
};
const p: Picked = {
키: got.시간당.로직,
이름: body.이름,
입력: { 기계: `'개인 ${tag}'` },
...(body.규격 ? { 규격: body.규격 } : {}),
};
formDirty = () => false;
close();
o.draft!(item, p, got.연결);
} catch (error) {
notice.replaceChildren(
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
);
} finally {
btn.disabled = false;
}
}
const blankBtn = createButton({
label: mt("Win_Blank"),
variant: "ghost",
onClick: () => {
const c: Cand = { kind: "draft", no: "", 이름: "", 규격: "", 계: null, 구분: "" };
sel = null;
void show(c, true, true);
},
});
blankBtn.dataset.mwinBlank = "";
({ close } = openModal({
title: mt("Win_Title"),
closeLabel: mt("Close"),
width: "1100px",
wide: true,
...(o.draft && ctx.canEdit ? { actions: [blankBtn] } : {}),
beforeClose: async () => !formDirty() || showConfirmDialog(mt("Leave_Ask")),
mount: (body) =>
body.append(
el("div", {
+317
View File
@@ -0,0 +1,317 @@
/* =============================================================================
* line_stack_machine_ctl.ts
* 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역
* ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값])
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
* 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import {
coverOf,
defaultsOf,
machineKey,
partsOf,
putCover,
type CrewRow,
type MachineCover,
type MiscRow,
} from "../common_util/common_util_formula_doc_cover";
import { subInputs, type StackDoc } from "../common_util/common_util_line_stack";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import { openStackPick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
export interface CtlHooks {
doc: StackDoc;
answer: DocAnswer;
subDoc: (key: string) => FormulaDoc | null;
/** 덮음 없이 그 기계 식 셈(원본 = 기본값) */
calc: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
change: (fn: (d: StackDoc) => void) => void;
name: (key: string) => string;
/** 읽기(M01 기초 · 칸 잠김) */
readOnly: boolean;
}
/** 기계 칸 단위(기계 표 `단위` 와 같음) */
const UNIT: Record<string, string> = {
가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr",
주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr",
}; // prettier-ignore
const FUELS = ["경유", "휘발유", "실내등유"];
/** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */
export const machineOfLine = (answer: DocAnswer, link: Link | undefined): string =>
link?.갈래 === "단가산출"
? machineKey(subInputs(link, (answer.값 ?? {}) as Record<string, unknown>))
: "";
/** 그 기계 줄의 기계 열쇠 · 식 · 원본 기본값 — 기계 식이 아니면 null */
export function ctlOf(h: CtlHooks, link: Link | undefined) {
if (link?.갈래 !== "단가산출" || !link.키) return null;
const given = subInputs(link, (h.answer.값 ?? {}) as Record<string, unknown>);
const machine = machineKey(given);
const sub = h.subDoc(link.키);
if (!machine || !sub) return null;
const orig = h.calc(sub, given);
const vals = (orig.값 ?? {}) as Record<string, unknown>;
return { machine, sub, vals, base: defaultsOf(sub, vals), entry: coverOf(h.doc)[machine] };
}
const num = (t: string): number | null => {
const n = Number(t.trim().replace(/,/g, ""));
return t.trim() !== "" && Number.isFinite(n) ? n : null;
};
/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 */
function numBox(
label: string,
value: unknown,
unit: string,
ro: boolean,
set: (n: number | null) => void,
hint = "",
): HTMLElement {
const f = createInputField({
value: value === undefined || value === null ? "" : String(value),
placeholder: hint,
compact: true,
size: 24,
...(unit ? { unit } : {}),
});
f.root.classList.add("lstk-f-s");
f.input.setAttribute("aria-label", label);
f.input.title = label;
f.input.disabled = ro;
f.input.addEventListener("change", () => {
const t = f.input.value;
const n = num(t);
if (t.trim() === "") return set(null);
if (n === null || n < 0) {
f.input.value = value === undefined || value === null ? "" : String(value);
return void showToast(lt("Ctl_Bad"), "warning");
}
set(n);
});
return f.root;
}
const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text });
/** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 */
export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElement | null {
const got = ctlOf(h, link);
if (!got) return null;
const { machine, sub, vals, base } = got;
const entry: MachineCover = got.entry ?? {};
const ro = h.readOnly;
const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base));
const parts = partsOf(sub);
const lines = sub.금액 ?? [];
// ㉠ 값 칸
const values = parts.fields.map(({ 칸: col }) => {
const was = base.값[col];
const mine = entry.값?.[col];
const now = mine ?? was;
const setVal = (v: number | string | null): void => {
const 값 = { ...(entry.값 ?? {}) };
if (v === null || v === "") delete 값[col];
else 값[col] = v;
put({ ...entry, 값 });
};
const box =
col === "주연료종류"
? createSelectField({
options: [...new Set([...FUELS, String(now ?? "")])]
.filter(Boolean)
.map((x) => ({ value: x, text: x })),
value: String(now ?? ""),
compact: true,
size: 24,
disabled: ro,
ariaLabel: col,
onChange: (x) => setVal(x),
}).root
: numBox(col, now, UNIT[col] ?? "", ro, setVal);
const row = el("div", {
className: "lstk-frow",
children: [el("b", { className: "lstk-sym", text: col }), "=", box],
});
if (mine !== undefined) {
const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 });
tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`;
row.append(tag);
if (!ro)
row.append(
createButton({
label: lt("Ctl_Back"),
variant: "ghost",
size: 24,
iconStart: "undo",
onClick: () => setVal(null),
}),
);
}
row.dataset.mctlField = col;
return row;
});
// ㉡ 조종원 — 인력 줄
const crew: CrewRow[] = entry.조종원 ?? base.조종원;
const setCrew = (rows: CrewRow[]): void => put({ ...entry, 조종원: rows });
const crewName = (r: CrewRow, i: number): string => {
if (r.이름) return r.이름;
const at = parts.crew[i] ?? parts.crew[0];
const sym = at === undefined ? "" : String(lines[at]?.단가 ?? "");
const v = (sub.변수 ?? []).find((x) => varId(x) === sym);
return (v?.이름 ?? "").replace(/노임$/, "") || h.name(r.키) || r.키;
};
const crewRows = crew.map((r, i) =>
lineRow(
lt("K_인력"),
crewName(r, i),
ro,
() => openStackPick("인력", (p) => setCrew(swapAt(crew, i, { ...r, 키: p.키, 이름: p.이름 })), undefined, crewName(r, i)), // prettier-ignore
[numBox(lt("Ctl_Crew_Qty"), r.인, lt("Ctl_Per_Day"), ro, (n) => setCrew(swapAt(crew, i, { ...r, 인: n ?? 0 })))], // prettier-ignore
"노무비",
() => setCrew(crew.filter((_, k) => k !== i)),
),
);
// ㉢ 잡품 — 재료 줄
const misc: MiscRow[] = entry.잡품 ?? base.잡품;
const setMisc = (rows: MiscRow[]): void => put({ ...entry, 잡품: rows });
const baseQty = (r: MiscRow): number | null => {
const b = lines.find((l) => String(l.이름 ?? "") === r.줄);
const q = b?.수량 ? (vals[String(b.수량)] ?? Number(b.수량)) : null;
return typeof q === "number" && Number.isFinite(q) ? q : null;
};
const miscName = (r: MiscRow): string => {
if (r.이름) return r.이름;
if (r.키) return h.name(r.키) || r.키;
const fuel = r.줄 === "주연료" ? vals.S_연료 : undefined;
return [r.줄, fuel].filter(Boolean).join(" · ");
};
const pickMat = (done: (p: Picked) => void, query: string): void =>
openFindPicker({
layer: "system",
owner: null,
title: `${lt("Find_Title")} — ${lt("Ctl_Misc")}`,
kinds: ["material", "oil"],
query: query || undefined,
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...(e.규격 ? { 규격: e.규격 } : {}) }), // prettier-ignore
oil: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위 }),
logic: () => undefined,
});
const asMat = (r: MiscRow, p: Picked): MiscRow => ({
키: p.키,
이름: [p.이름, p.규격].filter(Boolean).join(" "),
...(p.단위 ? { 단위: p.단위 } : {}),
수량: r.수량 ?? baseQty(r) ?? 1,
율: r.율,
});
const miscRows = misc.map((r, i) =>
lineRow(
lt("K_재료"),
miscName(r),
ro,
() => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""),
[
numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => {
const { 수량: _, ...rest } = r;
setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n }));
}, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : ""), // prettier-ignore
numBox(lt("Ctl_Rate"), r.율, "%", ro, (n) => setMisc(swapAt(misc, i, { ...r, 율: n ?? 0 }))), // prettier-ignore
],
"재료비",
() => setMisc(misc.filter((_, k) => k !== i)),
),
);
const head = (label: string, covered: boolean, add: () => void, reset: () => void) =>
el("div", {
className: "lstk-fhead",
children: [
el("span", { className: "lstk-kind", text: label }),
...(covered ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []),
...(ro
? []
: [
createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add }), // prettier-ignore
...(covered
? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: reset })] // prettier-ignore
: []),
]),
],
});
const box = el("div", {
className: "lstk-mctl lstk-wide",
attrs: { "data-mctl": machine },
children: [
...(values.length
? [el("div", { className: "lstk-fhead", children: [kind(lt("Ctl_Values"))] }), ...values]
: []),
head(
lt("Ctl_Crew"),
entry.조종원 !== undefined,
() => openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])),
() => put({ ...entry, 조종원: undefined }),
),
...(crewRows.length ? crewRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
head(
lt("Ctl_Misc"),
entry.잡품 !== undefined,
() => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""),
() => put({ ...entry, 잡품: undefined }),
),
...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
],
});
return box;
}
const swapAt = <T>(rows: T[], i: number, one: T): T[] => rows.map((r, k) => (k === i ? one : r));
/** 줄 꼴(단가산출 줄과 닮음) — [종류][이름 단추][칸들][비목][X] */
function lineRow(
label: string,
name: string,
ro: boolean,
pick: () => void,
boxes: HTMLElement[],
cost: string,
drop: () => void,
): HTMLElement {
const b = createButton({
label: name || "—",
variant: "ghost",
tone: "link",
size: 24,
iconStart: "search",
disabled: ro,
onClick: pick,
});
b.classList.add("lstk-res");
b.title = `${lt("Repick")} — ${name}`;
return el("div", {
className: "lstk-line k-ctl",
children: [
kind(label),
b,
...boxes,
el("span", { className: "lstk-hint", text: cost }),
...(ro ? [] : [createIconButton({ icon: "x", title: lt("Del"), size: 24, onClick: drop })]),
],
});
}
+20 -6
View File
@@ -15,12 +15,14 @@ import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
import { coverDoc, coverOf, type Cover } from "../common_util/common_util_formula_doc_cover";
import {
lineMap,
moneyScope,
offLine,
type Values,
} from "../common_util/common_util_formula_doc_view";
import { machineText } from "./formula_doc_options";
import { reach } from "./formula_doc_render";
import {
kindOf,
@@ -56,6 +58,10 @@ export interface OutHooks {
toggle: (at: string) => void;
/** 캐시에 없는 연결 — 뒤에서 서버에 물음 */
miss: () => void;
/** 836-1 기계 덮음(맨 위 문서 칸) — 펼친 기계 식에 얹음(계산기와 같음) */
cover?: Cover;
/** 838-3 기계 이름을 받은 뒤 다시 그림 */
redraw?: () => void;
}
const COST_SHOW: Record<string, string> = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" };
@@ -145,9 +151,11 @@ function rows(
};
const expand = (id: string): void => {
const path = `${at}/${id}`;
const sub = subOf(id);
if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return;
const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals));
const raw = subOf(id);
if (!h.open.has(path) || !raw || depth >= MAX_DEPTH) return;
const given = subInputs(vars.get(id)?.연결, vals);
const sub = coverDoc(raw, given, h.cover);
const got = calcDoc(sub, h.cache.links, given, [], new Map(), h.cover);
if (h.cache.takeMissing()) h.miss();
out.push({
depth: depth + 1,
@@ -175,7 +183,9 @@ function rows(
flush();
const res = isNum(v)
? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`)
: missing(a, id);
: typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름
? el("span", { text: machineText(v, () => h.redraw?.()) ?? v })
: missing(a, id);
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
const n = nodeOf(t)!;
const sym = tex(n, { side: "sym", vals, alias });
@@ -217,7 +227,11 @@ function rows(
head,
...expr,
" = ",
isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id),
isNum(v)
? `${eqText(v)} ${unit(id)}`
: typeof v === "string"
? (machineText(v, () => h.redraw?.()) ?? v)
: missing(a, id),
],
});
pack.push(cell);
@@ -408,7 +422,7 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement
depth: 0,
lv: 0,
};
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
for (const r of [head, ...rows(doc, a, { ...h, cover: coverOf(doc) }, 0, "")]) {
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
const v = r.cols?.[k];
if (v === undefined || (r.won && !v && k !== "sum")) return "";
+3
View File
@@ -24,6 +24,7 @@ import {
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view";
import { machineText } from "./formula_doc_options";
import {
dropSyms,
freshId,
@@ -157,6 +158,8 @@ export function buildBlock(
const realText = (t: string): string => aliasText(t, alias, true);
const valText = (id: string): string => {
const v = vals[id];
const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름
if (mach) return mach;
if (isNum(v) || typeof v === "string")
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
if (v && typeof v === "object")
+11 -8
View File
@@ -11,7 +11,8 @@
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
* 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · `line_stack_machine`) — 초안은 문서 칸(캐시) · [저장] 때 서버가 내 기계로
* 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · 836-1 찾기 · 바꾸기만) · 기계 내부 값 = 왼쪽 기계 제어 구역(836-1 ·
* `line_stack_machine_ctl` — 문서 칸 `기계덮음` · 캐시 · [저장] 때 서버가 다시 셈)
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
* 저장 전 문서(`memory` · 836-5 · 불러오기 미리 보기) = 빈 키 + 서버에 문서째(`본`) 읽음 · 왼쪽 줄 쌓기는 보기만(inert) · 디스크 쓰기 0
* ========================================================================== */
@@ -57,7 +58,6 @@ import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { buildEdit } from "./line_stack_edit";
import { openMachineWindow, type CopyAnswer, type MachineCtx } from "./line_stack_machine";
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
import { buildOut } from "./line_stack_out";
import { lt } from "./line_stack_text";
import "./line_stack.css";
@@ -283,18 +283,20 @@ export function createStackScreen(opt: StackScreenOptions) {
// ── 그림 ──
const edit = (): boolean => opt.admin && (!!key || !cp);
/** 827-1 기계 창 — 개인 층 M02 SK 만 [고쳐 쓰기] · M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */
/** 827-1 기계 창(찾기 · 바꾸기) — M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */
const machineCtx = (): MachineCtx => ({
scope: cp && opt.scope ? opt.scope : null,
canEdit: !!cp && !!key && opt.scope?.layer === "personal" && edit(),
why: !cp ? mt("Why_M01") : opt.scope?.layer === "personal" ? "" : mt("Why_Company"),
...(key ? { me: key } : {}),
...(cp && key
? { copyRow: (no: string) => call<CopyAnswer>(`/machine?no=${encodeURIComponent(no)}`) }
: {}),
});
/** 초안 기계 셈 기록(서버 확인 길 · 요소 · 표 · 식 문서) → 계산기 캐시 — [확인] 직후 브라우저가 바로 셈 */
const prime = (record: DocRecord): void => cache.merge(record);
/** 836-1 기계 제어 구역 기본값 — 덮음 없이 그 기계 식 셈(캐시에 없는 연결 = 뒤에서 서버) */
const calcSub = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer => {
const got = calcDoc(d, cache.links, g, [], new Map(), {});
if (cache.takeMissing()) ask();
return got;
};
const draw = (): void => {
if (!doc || !answer) {
[head, left, right, foot].forEach((x) => x.replaceChildren());
@@ -372,7 +374,7 @@ export function createStackScreen(opt: StackScreenOptions) {
pair: (loc) => pair(loc, "left"),
take: takeCtx(),
machine: machineCtx(),
prime,
calc: calcSub,
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
}),
]
@@ -391,6 +393,7 @@ export function createStackScreen(opt: StackScreenOptions) {
},
miss: ask,
pair: (loc) => pair(loc, "right"),
redraw: () => draw(),
})
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
);
+18
View File
@@ -90,6 +90,24 @@ const TEXT = {
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
"Row follows the option above (change it there; pick a column here)",
],
Ctl_Open: ["기계 값 · 조종원 · 잡품 고치기", "Edit machine values · operator · sundries"],
Ctl_Values: ["기계 값", "Machine values"],
Ctl_Crew: ["조종원", "Operator"],
Ctl_Misc: ["잡품", "Sundries"],
Ctl_Add: ["더함", "add"],
Ctl_Changed: ["고침", "Edited"],
Ctl_Was: ["원래 값", "Original"],
Ctl_Back: ["원래 값", "Original"],
Ctl_Bad: ["0 이상 수만 넣을 수 있음", "Enter a number 0 or more"],
Ctl_Crew_Qty: ["인원", "Persons"],
Ctl_Per_Day: ["인/일", "per day"],
Ctl_Rate: ["율", "Rate"],
Ctl_Follow: ["(따름)", "(follows)"],
Ctl_None: ["없음", "None"],
Win_Ctl_Note: [
"기계 내부 값(가격 · 연료 · 조종원 · 잡품)은 단가산출 왼쪽 기계 줄의 ▸ 에서 고침",
"Edit machine internals from the ▸ on the left machine line",
],
} as const;
export type LsKey = keyof typeof TEXT;
+8 -2
View File
@@ -7,7 +7,7 @@
그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 모두 고정 · 붙여넣기 · 다시 잇기 ·
구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 ·
단가산출 만들기)은 785-7 에서 지움.
층 권한 = `Router_Items._root` · ⚠ 등록은 `main.py`(로그인만).
층 = 늘 개인(요구 855 · 위 `_root` 가 고정) · ⚠ 등록은 `main.py`(로그인만).
"""
from __future__ import annotations
@@ -33,11 +33,17 @@ from M02_MasterTemplete import M02_Hopyo_View as hopyo_view_
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_San_MatPick as mat_pick
from M02_MasterTemplete import M02_Sangeun_FromHopyo as from_hopyo
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import _root as _layer_root
router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"])
def _root(session: Session, layer: str, owner: str | None = None, write: bool = False) -> Path:
"""불러오기 자리 = 늘 자기 개인 층(요구 855) — 경로 층 · 주인 글 · 몸 층은 받되 무시(회사 · 마스터 · 남의 층 불가)."""
return _layer_root(session, "personal", None, write=write)
# ── [수정] 보기(809-1) — 경쟁사 로직 인정 · 요소만 연결 · 읽기만 ─────────────────
@@ -1,8 +1,8 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Import.ts
* 일위대가 불러오기 본 칸 보기(PLAN 785-4 · 요구 642-11 — 마법사 `Hopyo_Wizard` 갈음 · 가운데 상세 페이지 없음) —
* 왼쪽 [불러오기] → 층 · 파일 고름 → 올림(`Hopyo_Side`) → 바로 이 보기:
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 층 · [끝내기]를 덧붙임
* 왼쪽 [불러오기] → 파일 고름 → 올림(`Hopyo_Side` · 층 가르기 없음 — 누가 하든 자기 개인 층 · 요구 855) → 바로 이 보기:
* · 머리 막대 = 엑셀 보기 몸(`Hopyo_Sheet` — 시트 · 호표 · 검산 · 연결)의 머리에 [끝내기]를 덧붙임
* · 아래 = 엑셀(탭 셋) + 오른쪽 호표 · 단가산출 목록(`Hopyo_Sheet` · `Hopyo_SheetSk`) · 만들기 = 호표 줄 [내 일위대가로](807-1)
* · [끝내기] = 물음(안 만든 단가산출 알림) → 서버가 이번 HP · 격자 저장본 지움 ·
* 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 · 옛 「안 이은 줄」 띠는 785-7 에서 지움(판정 「고를 것」)
@@ -10,34 +10,19 @@
* 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림)
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
import { deleteHopyo, type HopyoFile, type HopyoScope } from "./M02_MasterTemplete_Hopyo_Api";
import type { HopyoSheetBody } from "./M02_MasterTemplete_Hopyo_Sheet";
import { accessFor, type ManageLayer } from "./M02_MasterTemplete_Layer";
import "./M02_MasterTemplete_Hopyo.css";
export const LAYERS: { layer: ManageLayer; text: string }[] = [
{ layer: "personal", text: "개인" },
{ layer: "company", text: "회사" },
{ layer: "system", text: "마스터" },
];
export const layerText = (layer: string): string =>
LAYERS.find((x) => x.layer === layer)?.text ?? layer;
/** 불러오기 자리 = 늘 자기 개인 층(요구 855 — 층 가르기 없음) */
export const MINE: HopyoScope = { layer: "personal", owner: null };
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
/** 쓸 수 있는 자기 층(개인 · 회사 · 마스터 차례) */
export async function writableLayers(): Promise<ManageLayer[]> {
const user = await fetchSessionUser().catch(() => null);
return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite);
}
export interface HopyoImportArgs {
layer: ManageLayer;
file: HopyoFile;
/** [끝내기] 로 올린 파일을 지웠음 */
onEnded: () => void;
@@ -52,8 +37,8 @@ export interface HopyoImport {
}
export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
const { layer, file } = a;
const scope: HopyoScope = { layer, owner: null };
const { file } = a;
const scope = MINE;
let held = true;
let body: HopyoSheetBody | null = null;
@@ -74,7 +59,6 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
// ── 머리 막대에 덧붙일 것 ──
const finish = createButton({ label: "끝내기", variant: "filled", size: 28, onClick: () => void end() }); // prettier-ignore
finish.dataset.hopyoFinish = "";
const layerNote = createBadge(`${layerText(layer)} 층`, "neutral");
async function end(): Promise<void> {
finish.disabled = true;
@@ -111,7 +95,7 @@ export function buildHopyoImport(a: HopyoImportArgs): HopyoImport {
파일: file.파일,
target: scope,
canWrite: true,
headExtra: [layerNote, finish],
headExtra: [finish],
});
body = view;
root.replaceChildren(...view.parts);
@@ -3,8 +3,8 @@
* 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시 · 785-4) —
* · [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) = 바로 파일 하나 고르기 창(엑셀 · 경쟁사 .estx · .STC · 814-1) → 올림 → 본 칸 불러오기 보기
* (`M02_MasterTemplete_Hopyo_Import` · 엑셀 + 오른쪽 호표 목록 · 가운데 상세 페이지 없음 · 누름 하나)
* · 결과 층 고름 = 작은 고름(쓸 수 있는 층 둘 이상일 때만 · 일반 = 개인 하나라 안 보임)
* · 「작업 중」 줄 = 층마다 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
* · 층 가르기 없음 — 누가 하든 올린 것 · 만든 것 = 자기 개인 층(요구 855 · 공유는 만든 뒤 본인이)
* · 「작업 중」 줄 = 자기 개인 층 서버 목록(같은 엑셀 작업 중 이어서) · 누르면 본 칸 불러오기 보기
* · 다른 엑셀 [불러오기] · 다른 화면으로 = 지금 보기 떠남(올린 파일 지움 · 743-1) — 고르고 안 만든 확인 캐시가 있으면 물음 한 번
* 옛 [별칭 장부](338-3)는 걷음(815-2) — 별칭은 M01 요소 「별칭」 칸 · M01 별칭 모아 보기.
* ========================================================================== */
@@ -12,22 +12,9 @@
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { createListRow } from "@ui/ui_template_list_row";
import { createSegment } from "@ui/ui_template_segment";
import { t as L } from "@ui/ui_template_locale";
import {
listHopyo,
uploadHopyo,
type HopyoFile,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import {
buildHopyoImport,
LAYERS,
layerText,
writableLayers,
type HopyoImport,
} from "./M02_MasterTemplete_Hopyo_Import";
import type { ManageLayer } from "./M02_MasterTemplete_Layer";
import { listHopyo, uploadHopyo, type HopyoFile } from "./M02_MasterTemplete_Hopyo_Api";
import { buildHopyoImport, MINE, type HopyoImport } from "./M02_MasterTemplete_Hopyo_Import";
export interface HopyoSectionOptions {
/** 저장 안 한 고침을 버릴지 확인 — 취소면 false */
@@ -46,12 +33,9 @@ export interface HopyoSection {
}
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null });
export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
const title = L("M02_HopyoImport");
let layers: ManageLayer[] = [];
let pick: ManageLayer = "personal";
const input = el("input", {
attrs: { type: "file", accept: ".xls,.xlsx,.estx,.stc", "data-hopyo-file": "" },
}) as HTMLInputElement;
@@ -65,50 +49,25 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
start.title =
"파일 하나 — 경쟁사 엑셀(.xls · .xlsx) 또는 경쟁사 파일(.estx · .STC) → 우리 정보로 다시 만듦(한시 · 끝내거나 떠나면 올린 파일 지움)";
start.dataset.hopyoWizardOpen = "";
const seg = el("div", { className: "m02-side__row" });
seg.hidden = true;
const jobs = el("div", { className: "m02-hopyo-side", attrs: { "data-hopyo-jobs": "" } });
const body = el("div", {
className: "m02-hopyo-side",
children: [el("div", { className: "m02-side__row", children: [start, input] }), seg, jobs],
children: [el("div", { className: "m02-side__row", children: [start, input] }), jobs],
});
let open: { key: string; view: HTMLElement; imp?: HopyoImport } | null = null;
/** 층 고름 · 작업 중 줄 — 쓸 수 있는 층을 처음 한 번 읽음 */
/** 작업 중 줄 — 자기 개인 층 서버 목록 */
async function refresh(): Promise<void> {
if (!layers.length) {
layers = await writableLayers();
if (!layers.includes(pick)) pick = layers[0] ?? "personal";
if (layers.length > 1) {
const s = createSegment({
options: LAYERS.filter((x) => layers.includes(x.layer)).map((x) => ({ value: x.layer, label: x.text })), // prettier-ignore
value: pick,
size: 24,
onChange: (v) => (pick = v as ManageLayer),
});
s.root.dataset.hopyoLayer = "";
seg.replaceChildren(s.root);
seg.hidden = false;
}
}
const rows = (
await Promise.all(
layers.map((layer) =>
listHopyo(scopeOf(layer))
.then((list) => list.map((f) => ({ layer, f })))
.catch(() => []),
),
)
).flat();
const rows = await listHopyo(MINE).catch(() => []);
jobs.replaceChildren(
...(rows.length
? rows.map(({ layer, f }) => {
? rows.map((f) => {
const row = createListRow({
label: f.파일,
sub: `호표 ${f.호표수} · ${layerText(layer)} — 이어서`,
sub: `호표 ${f.호표수} — 이어서`,
size: 28,
active: open?.imp !== undefined && open.key === f.키,
onClick: () => void show(layer, f),
onClick: () => void show(f),
});
row.dataset.hopyoKey = f.키;
return row;
@@ -119,8 +78,6 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
/** [불러오기] — 지금 보기를 떠날 수 있는지 묻고 바로 파일 고르기 창 */
async function choose(): Promise<void> {
if (!layers.length) await refresh().catch(() => {});
if (!layers.length) return void showToast("쓸 수 있는 층 없음", "warning");
if (!(await leaveOk())) return;
input.click();
}
@@ -129,14 +86,14 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
input.value = "";
if (!file) return;
start.disabled = true;
uploadHopyo(scopeOf(pick), file)
uploadHopyo(MINE, file)
.then((got: HopyoFile & { 이어서?: boolean; 원파일?: boolean }) => {
const tool = /\.estx$/i.test(file.name) ? "EST" : "STmate"; // 810-3 원파일 올림 안내
if (got.이어서) showToast(`같은 파일 작업 중 — ${got.키} 이어서`, "info");
else if (got.원파일) showToast(`${tool} 파일 — 단가산출표 · 산출근거`, "success");
else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success");
if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning");
return show(pick, got, true);
return show(got, true);
})
.catch((error) => showToast(why(error), "error"))
.finally(() => (start.disabled = false));
@@ -153,12 +110,11 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection {
}
/** 본 칸에 불러오기 보기 — 같은 파일이면 그대로 */
async function show(layer: ManageLayer, file: HopyoFile, checked = false): Promise<void> {
async function show(file: HopyoFile, checked = false): Promise<void> {
if (open?.imp && open.key === file.키 && open.view.isConnected) return;
if (!checked && !(await leaveOk())) return;
open?.imp?.leave(); // 불러오기 떠남 = 올린 파일 지움(743-1)
const imp = buildHopyoImport({
layer,
file,
onEnded: () => {
open = null;
+5 -5
View File
@@ -5,7 +5,7 @@
가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` ·
`/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) ·
불러오기 단산 블록 → 가져온 식(793-3b · `M02_Sangeun_FromHopyo`) `…/sangeun/from-hopyo`
(만들 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1).
(늘 개인 층 쓰기 · 몸 `문서` = 미리 보기에서 고친 문서) · 미리 보기 `…/from-hopyo/preview`(층 읽기 · 안 씀 · 835-1).
로직 사본(795-1 · `M02_Sangeun_Copy`) `…/sangeun/copy/price` [단가 갱신] · `/regen` [재생성]
(화면 캐시 문서에 할일 얹음 · 저장 없음 · 층 읽기).
수식 탭(796-2b · `M02_Sangeun_Equation`) `…/sangeun/equation`(층 읽기) · `/preview`(고침 미리 셈 ·
@@ -176,8 +176,8 @@ def sangeun_from_hopyo(
) -> dict:
"""불러오기 단산 블록 → 가져온 식 SK(793-3b) — 경로 층 = 만들 층 · 몸 층 = 파일 층 ·
답 = {이름, 검산, 단가기준, 다시씀}(같은 내용 = 그 SK)."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
folder = _root(session, "personal", None, write=True) # 불러오기 = 늘 개인 층(요구 855)
source = _root(session, "personal", None)
mine = _root(session, "personal", None, write=body.고름 is not None)
fp, name = _call(source, mat_pick.fingerprint, source, body.키) # 827-3 그 파일 재료 고름
if body.고름 is not None and fp:
@@ -200,8 +200,8 @@ def sangeun_from_hopyo_preview(
) -> dict:
"""[가져온 식으로 열기] 미리 보기(835-1 · 요구 852-1) — 층에 안 씀(재료 고름도 메모리) ·
답 = {문서, 검산, 같은것(같은 내용 SK 키 | None)} · [저장] = `from-hopyo` 몸 `문서`."""
folder = _root(session, layer, owner)
source = _root(session, body.층, body.주인)
folder = _root(session, "personal", None) # 불러오기 = 늘 개인 층(요구 855)
source = _root(session, "personal", None)
mine = _root(session, "personal", None)
fp, _ = _call(source, mat_pick.fingerprint, source, body.키)
picks = mat_pick.merge(mat_pick.read(mine, fp)["고름"], body.고름) if fp else {}
+6 -1
View File
@@ -41,6 +41,8 @@ const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" };
export interface CrewRow {
키: string;
인: number;
/** 화면 이름(직종) — 셈 · 원본 대조 안 봄 */
이름?: string;
}
export interface MiscRow {
줄?: string;
@@ -166,12 +168,14 @@ export function defaultsOf(doc: FormulaDoc, vals: Vals): Required<MachineCover>
return { 값, 조종원, 잡품 };
}
/** 원본 대조 꼴 — 이름 · 단위(화면 글) · 빈 칸 뺌 · 수 12 자리 */
const LABEL = ["이름", "단위"];
const canon = (v: unknown): string =>
JSON.stringify(v, (_, x: unknown) =>
x && typeof x === "object" && !Array.isArray(x)
? Object.fromEntries(
Object.entries(x as Record<string, unknown>)
.filter(([, y]) => y !== undefined && y !== null && y !== "")
.filter(([k, y]) => y !== undefined && y !== null && y !== "" && !LABEL.includes(k))
.sort(([a], [b]) => (a < b ? -1 : 1)),
)
: typeof x === "number"
@@ -238,6 +242,7 @@ function crewLines(doc: FormulaDoc, rows: CrewRow[]): MoneyLine[] {
}
return rows.map((r) => ({
이름: "조종원",
...(r.이름 ? { 규격: r.이름 } : {}),
단위: "인",
비목: "노무비",
수량: `${plain(r.인)} × ${HOUR}`,
@@ -145,10 +145,15 @@ def defaults_of(doc: dict, vals: dict) -> dict:
return {"값": values, "조종원": crew, "잡품": misc}
_LABEL = ("이름", "단위") # 화면 글 — 원본 대조 안 봄
def _canon(v) -> str:
def norm(x):
if isinstance(x, dict):
return {k: norm(y) for k, y in sorted(x.items()) if y not in (None, "")}
return {
k: norm(y) for k, y in sorted(x.items()) if y not in (None, "") and k not in _LABEL
}
if isinstance(x, list):
return [norm(y) for y in x]
if _num(x) is not None:
@@ -209,7 +214,8 @@ def _crew_lines(doc: dict, rows: list) -> list[dict]:
)
doc.setdefault("식", []).append(dict(_HOUR_LINE))
return [
{"이름": "조종원", "단위": "인", "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
{"이름": "조종원", **({"규격": r["이름"]} if r.get("이름") else {}), "단위": "인",
"비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
"단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])}
for r in rows
] # fmt: skip
+3 -2
View File
@@ -352,10 +352,11 @@ def test_구조물_조합으로_규격_묶음_울진_돌기슭막이(client):
assert master_hash() == before
def test_구조물_조합으로_남의_층_403(client):
def test_구조물_조합으로_경로_층_무시_늘_개인(client): # 837-1 · 요구 855 — 옛 「남의 층 403」 갈음
key = _upload(client, "대흥")
body = {"층": "personal", "키": key, "번": 17}
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 403
assert client.post("/api/m02/layers/company/hopyo-item", json=body).status_code == 200
assert list((layers_dir() / "labor").glob("*.json")) # 쓴 곳 = 개인 층(`layers_dir`)
def test_구조물_조합으로_같은_내용_409_씀_새로(client):
+10 -10
View File
@@ -27,20 +27,20 @@ def test_누름_하나_바로_엑셀():
choose = side.split("async function choose()")[1].split("input.addEventListener")[0]
assert "input.click();" in choose # [불러오기] = 바로 파일 고르기 창
change = side.split('input.addEventListener("change"')[1].split("async function leaveOk")[0]
assert (
"uploadHopyo(scopeOf(pick), file)" in change and "return show(pick, got, true);" in change
)
assert "uploadHopyo(MINE, file)" in change and "return show(got, true);" in change
show = side.split("async function show(")[1].split("async function showLedger")[0]
assert "buildHopyoImport({" in show and "opt.show(" in show # 올림 뒤 본 칸 바로
imp = _src("Import")
assert "m.buildHopyoSheet({" in imp and "headExtra: [layerNote, finish]" in imp
assert "m.buildHopyoSheet({" in imp and "headExtra: [finish]" in imp
def test_결과_층_고름_둘_이상일_때만():
side = _src("Side")
assert "layers.length > 1" in side and "seg.hidden = false;" in side
assert "seg.hidden = true;" in side # 처음 숨김 — 일반(개인 하나)은 안 보임
assert 'let pick: ManageLayer = "personal";' in side
def test_층_가르기_없음_늘_개인(): # 837-1 · 요구 855 — 예전 「결과 층 고름」 걷음
side, imp = _src("Side"), _src("Import")
for gone in ("createSegment", "writableLayers", "LAYERS", "layerText", "seg.hidden", "pick"):
assert gone not in side, gone
for gone in ("LAYERS", "layerText", "writableLayers", "layerNote", "createBadge"):
assert gone not in imp, gone
assert 'export const MINE: HopyoScope = { layer: "personal", owner: null };' in imp
def test_끝내기_머리_막대_끝냄_뒤_빈_본_칸():
@@ -71,5 +71,5 @@ def test_부품_상자만_700줄_늘리지_않음():
def test_해시_캐시_열쇠_없음():
# 809-1 — 판정 고름 캐시(`Hopyo_Picks`) 걷음 · 옮기기 = [내 일위대가로]
assert not (M02 / "logic" / "M02_MasterTemplete_Hopyo_Picks.ts").exists()
assert "draftKey" not in _src("Import") and "dirty: () => false" in _src("Import")
assert "draftKey" not in _src("Import") and "dirty: () => body?.matDirty()" in _src("Import")
assert "location.hash" not in _src("Import") + _src("Side") # 새 해시 열쇠 없음
+6 -1
View File
@@ -229,7 +229,7 @@ def _same(a, b, where=""):
COVERS = [
None,
{"값": {"가격": 150000, "주연료종류": "휘발유"}},
{"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]},
{"조종원": [{"키": "LB:1048", "인": 2, "이름": "운전사"}, {"키": "LB:1002", "인": 0.5}]},
{"조종원": []},
{"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]},
{"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]},
@@ -334,3 +334,8 @@ def test_저장_다시_열기_되돌리면_덮음_없음(world):
doc[mcv.COVER] = {NO: {"값": {"가격": -5}}}
with pytest.raises(store.StoreError):
at(path.save, key, {"문서": doc, "판": back["판"]})
def test_이름_단위는_원본_대조_안_봄():
base = {"값": {}, "조종원": [{"키": "LB:1048", "인": 1}], "잡품": []}
assert mcv.prune({"조종원": [{"키": "LB:1048", "인": 1, "이름": "운전사"}]}, base) is None
@@ -0,0 +1,46 @@
"""837-1 일위대가 불러오기 = 늘 자기 개인 층(요구 855) — 서버 · 화면 없이 돎.
불러오기 길(`M02_Hopyo_Router` · `Sangeun_Router` from-hopyo)은 경로 층 · 주인 · 몸 층을 무시하고 개인 층만 씀."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from M02_MasterTemplete import M02_Hopyo_Router as hopyo # noqa: E402
def test_불러오기_길_층_고정(monkeypatch):
seen: list[tuple] = []
monkeypatch.setattr(
hopyo,
"_layer_root",
lambda s, layer, owner=None, write=False: seen.append((layer, owner, write)),
)
for layer, owner in (("company", "3"), ("system", None), ("personal", "3/9")):
hopyo._root({}, layer, owner, write=True)
hopyo._root({}, layer, owner)
assert seen == [("personal", None, True), ("personal", None, False)] * 3
def test_라우터_루트_모두_고정_함수():
src = (ROOT / "M02_MasterTemplete" / "M02_Hopyo_Router.py").read_text(encoding="utf-8")
assert "import _root as _layer_root" in src
assert "_layer_root(" in src.split("def _root(")[1].split("\n\n")[0]
assert src.count("_layer_root(") == 1 # 층 고르는 길은 이 한 곳뿐
def test_단산_블록_만들기_개인_층만():
src = (ROOT / "M02_MasterTemplete" / "M02_Sangeun_Router.py").read_text(encoding="utf-8")
for fn in ("def sangeun_from_hopyo(", "def sangeun_from_hopyo_preview("):
body = src.split(fn)[1].split("\n@router")[0]
assert "body.층" not in body and "_root(session, layer" not in body, fn
assert '_root(session, "personal", None' in body, fn
def test_화면_층_고름_없음():
m02 = ROOT / "M02_MasterTemplete"
side = (m02 / "M02_MasterTemplete_Hopyo_Side.ts").read_text(encoding="utf-8")
imp = (m02 / "M02_MasterTemplete_Hopyo_Import.ts").read_text(encoding="utf-8")
assert "createSegment" not in side and "writableLayers" not in side + imp
assert '"회사"' not in side + imp and '"마스터"' not in side + imp