Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-01 09:51:36 +09:00
4 changed files with 197 additions and 32 deletions
+106 -5
View File
@@ -53,9 +53,12 @@ export interface EasySaved {
row: LogicRow;
}
/** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직 */
/** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직
* `where` = 회사 · 개인 층 조합의 자리 글(「회사 · 산림조합」) · 마스터 층 · 로직은 없음
* 층을 못 읽었으면 `key` "" · `name` = 「일부를 못 읽음」 한 줄(지우기 전에 확인하게) */
export interface EasyUse {
kind: "combo" | "logic";
where?: string;
key: string;
name: string;
}
@@ -158,6 +161,14 @@ const TEXT = {
"이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요",
"No hourly cost data for this machine — pick another",
],
AtCompany: ["회사 · {name}", "Company · {name}"],
AtPerson: ["개인 · {name}", "Personal · {name}"],
MyCompany: ["내 회사", "My company"],
Me: ["나", "Me"],
Unseen: [
"회사 · 개인 조합 일부를 못 읽음 — 지우기 전에 확인 필요",
"Some company or personal combos could not be read — check before deleting",
],
} as const satisfies Record<string, readonly [string, string]>;
type SayKey = keyof typeof TEXT;
@@ -256,6 +267,26 @@ export function machineCall(m: MachineLogic, code: string): EasyMachineElement {
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs };
}
/** 층 조합 줄 통째(`담은로직` 포함) */
export interface LayerCombo {
키: string;
이름?: string;
담은로직?: { 로직?: string }[];
}
/** 이 로직을 담은 층 조합 — `layer` = company · personal · `name` = 층 주인 이름 */
export function holding(
combos: LayerCombo[],
key: string,
layer: "company" | "personal",
name: string,
): EasyUse[] {
const where = say(layer === "company" ? "AtCompany" : "AtPerson", { name });
return combos
.filter((c) => (c.담은로직 ?? []).some((one) => one?.로직 === key))
.map((c): EasyUse => ({ kind: "combo", where, key: c.키, name: c.이름 ?? "" }));
}
/* ── 순수 끝 ── */
const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`)
@@ -263,10 +294,15 @@ const OWNER = "현장";
const CALC_WAIT_MS = 300;
const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」)
async function request<T>(path: string, body?: unknown): Promise<T> {
/** `/api/m01` 길 · M02 층 조합 읽기는 `m02` */
const request = <T>(path: string, body?: unknown): Promise<T> =>
send<T>(`${API_BASE_URL}/m01${path}`, body);
const m02 = <T>(path: string): Promise<T> => send<T>(`${API_BASE_URL}/m02${path}`);
async function send<T>(url: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01${path}`, {
response = await fetch(url, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
@@ -385,13 +421,15 @@ export async function deleteEasy(key: string, version: string): Promise<{ versio
return { version: got.version };
}
/** 쓰는 곳 — 담은 일위대가 조합(마스터 층) + 이 로직을 부르는 내가 만든 것.
/** 쓰는 곳 — 담은 일위대가 조합(마스터 · 회사 · 개인 층) + 이 로직을 부르는 내가 만든 것.
* 지우기 누를 때 부름(무거움) · 서버 지우기 검사는 마스터 층 조합만 막음.
* ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) —
* 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */
export async function usesOf(key: string): Promise<EasyUse[]> {
const [combos, mine] = await Promise.all([
const [combos, mine, layered] = await Promise.all([
request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`),
request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`),
layerUses(key),
]);
const call = `로직(${key}`;
const others = mine.logics.filter((s) => s.키 !== key);
@@ -401,10 +439,73 @@ export async function usesOf(key: string): Promise<EasyUse[]> {
.map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 }));
return [
...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })),
...layered,
...callers,
];
}
interface LayerTarget {
layer: "company" | "personal";
owner: string;
name: string;
rows?: { 키: string }[];
}
/** 회사 · 개인 층 조합 가운데 이 로직을 담은 것 — M02 층 길 읽기만(시스템 관리자는 모든 층).
* 내 층 = `/combos/all` · 남의 회사 = `/layers/company/owners` · 회사마다 사람 = `/layers/personal/owners`.
* 못 읽은 층이 있으면 「일부를 못 읽음」 한 줄을 더함.
* ponytail: 층마다 조합 하나씩 받아 훑음 — 조합이 수천이면 서버에 「이 로직을 담은 층 조합」 길을 더할 것 */
async function layerUses(key: string): Promise<EasyUse[]> {
let missed = false;
const soft = <T>(p: Promise<T>, empty: T): Promise<T> =>
p.catch(() => {
missed = true;
return empty;
});
type Own = { layer: string; owner: string | null; 줄: { 키: string }[] };
type Owner = { owner: string; name: string };
const own = await soft(m02<Own[]>("/combos/all"), []);
const targets: LayerTarget[] = [];
let myCompany = "";
for (const one of own) {
if (one.layer === "system" || !one.owner) continue;
const company = one.layer === "company";
if (company) myCompany = one.owner;
targets.push({
layer: company ? "company" : "personal",
owner: one.owner,
name: say(company ? "MyCompany" : "Me"),
rows: one.줄,
});
}
const companies = await soft(m02<Owner[]>("/layers/company/owners"), []);
for (const c of companies) targets.push({ layer: "company", ...c });
const people = await Promise.all(
[myCompany, ...companies.map((c) => c.owner)]
.filter(Boolean)
.map((c) => soft(m02<Owner[]>(`/layers/personal/owners?${query({ company: c })}`), [])),
);
for (const p of people.flat()) targets.push({ layer: "personal", ...p });
const found = await Promise.all(
targets.map(async (t) => {
const at = `/layers/${t.layer}/combos`;
const own = `?${query({ owner: t.owner })}`;
const rows = t.rows ?? (await soft(m02<{ 줄: { 키: string }[] }>(at + own), { 줄: [] })).줄;
const combos = await Promise.all(
rows.map((r) =>
soft(m02<{ combo: LayerCombo }>(`${at}/${encodeURIComponent(r.키)}${own}`), null),
),
);
const full = combos.flatMap((x) => (x ? [x.combo] : []));
return holding(full, key, t.layer, t.name);
}),
);
const out = found.flat();
if (missed) out.push({ kind: "combo", key: "", name: say("Unseen") });
return out;
}
let family: Promise<MachineLogic[]> | null = null;
/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */
@@ -124,6 +124,22 @@
opacity: 0.5;
}
.m01-easytable__hint {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-8);
margin: 0;
padding: var(--spacing-8) var(--spacing-12);
border-radius: var(--radius-lg);
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
font-size: var(--text-body-sm);
}
.m01-easytable__hint:empty {
display: none;
}
.m01-easytable__warn {
margin: 0;
color: var(--color-danger);
+47 -27
View File
@@ -45,6 +45,10 @@ const TEXT = {
Total: ["합계", "Total"],
Wait: ["계산 중", "Calculating"],
Stopped: ["계산 멈춤", "Calculation stopped"],
DesignHint: [
"이 값은 설계 때 정함 — 늘 이 줄로 하려면",
"This value is set at design — to always use this row,",
],
Mismatch: [
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
"The picked row isn't matched by these conditions",
@@ -351,6 +355,8 @@ interface CallState {
picked: number | null;
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
tryValues: Record<string, unknown>;
/** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */
designHint: string[];
}
const showCell = (v: Cell): string => {
@@ -367,7 +373,7 @@ function startState(call: FindCall, doc: EasyDoc): CallState {
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
});
return { conds, col: call.col, picked: null, tryValues: {} };
return { conds, col: call.col, picked: null, tryValues: {}, designHint: [] };
}
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
@@ -465,6 +471,7 @@ async function mountTable(
const grid = el("div", { className: "m01-easytable__grid" });
const money = el("div", { className: "m01-easytable__money" });
const warn = el("p", { className: "m01-easytable__warn" });
const hint = el("p", { className: "m01-easytable__hint" });
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
const cancel = createButton({
label: tt("Cancel"),
@@ -491,6 +498,7 @@ async function mountTable(
const st = states[now];
const row = table?.줄[idx];
if (!table || !row) return;
st.designHint = [];
for (const key of Object.keys(table.조건)) {
if (row[key] === undefined) continue;
let c = st.conds.find((x) => x.key === key);
@@ -504,6 +512,7 @@ async function mountTable(
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
: row[key];
st.tryValues[c.input] = n;
st.designHint.push(key);
} else {
c.mode = "fix";
c.fix = row[key];
@@ -521,9 +530,36 @@ async function mountTable(
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
}
c.mode = mode;
states[now].designHint = states[now].designHint.filter((k) => k !== c.key);
void refresh();
};
/** 누른 줄의 「설계 때 정함」 조건을 한 번에 그 줄 값으로 고정 */
const fixPicked = (): void => {
const st = states[now];
const row = st.picked === null ? null : tbl[now]?.줄[st.picked];
if (!row) return;
for (const c of st.conds)
if (st.designHint.includes(c.key)) {
c.mode = "fix";
c.fix = row[c.key];
}
st.designHint = [];
void refresh();
};
const paintHint = (): void => {
const st = states[now];
hint.replaceChildren(
...(st.picked !== null && st.designHint.length
? [
el("span", { text: `${tt("DesignHint")} ` }),
createButton({ label: tt("Fix"), size: "sm", onClick: fixPicked }),
]
: []),
);
};
const paintConds = (): void => {
const st = states[now];
condBox.replaceChildren(
@@ -579,10 +615,7 @@ async function mountTable(
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
const rows = table.줄.map((row, idx) => {
const tr = el("tr", {
className: [
idx === hit && !busy ? "is-hit" : "",
idx === st.picked ? "is-picked" : "",
].join(" "),
className: `${idx === hit && !busy ? "is-hit" : ""} ${idx === st.picked ? "is-picked" : ""}`,
children: [
...conds.map((k) => el("td", { text: showCell(row[k]) })),
...vals.map((k) =>
@@ -600,28 +633,13 @@ async function mountTable(
});
return tr;
});
grid.replaceChildren(
el("table", {
children: [
el("thead", {
children: [
el("tr", {
children: [
...conds.map((k) => th(k)),
...vals.map((k) =>
th(
`${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`,
k === st.col ? "is-col" : "",
),
),
],
}),
],
}),
el("tbody", { children: rows }),
],
}),
);
const unit = (k: string): string => (table.값칸[k] ? `${k} (${table.값칸[k]})` : k);
const top = [
...conds.map((k) => th(k)),
...vals.map((k) => th(unit(k), k === st.col ? "is-col" : "")),
];
const thead = el("thead", { children: [el("tr", { children: top })] });
grid.replaceChildren(el("table", { children: [thead, el("tbody", { children: rows })] }));
};
const paint = (): void => {
@@ -646,6 +664,7 @@ async function mountTable(
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
);
paintConds();
paintHint();
paintGrid();
const picked = states[now].picked;
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
@@ -667,6 +686,7 @@ async function mountTable(
which,
head,
condBox,
hint,
grid,
money,
warn,
+28
View File
@@ -282,6 +282,34 @@ def test_기계_부름글이_서버_검사와_계산을_지남(client: TestClien
_call(client, "/api/m01/logic/delete", {"key": made["key"], "version": made["version"]})
# ── 회사 · 개인 층 조합 ────────────────────────────────────────────────
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_층_조합_가운데_이_로직을_담은_것만_자리_글과_함께() -> None:
combos = [
{
"키": "UC3-000001",
"이름": "돌쌓기 한 벌",
"담은로직": [{"로직": "GX000001", "메모": None}],
},
{"키": "UC3-000002", "이름": "다른 것", "담은로직": [{"로직": "GX000002"}]},
{"키": "UC3-000003", "이름": "빈 것"},
{
"키": "UC3-000004",
"이름": "둘째 자리",
"담은로직": [{"로직": "GF000001"}, {"로직": "GX000001"}],
},
]
got = _node(
f"holding({json.dumps(combos, ensure_ascii=False)}, 'GX000001', 'company', '산림조합')"
)
assert got == [
{"kind": "combo", "where": "회사 · 산림조합", "key": "UC3-000001", "name": "돌쌓기 한 벌"},
{"kind": "combo", "where": "회사 · 산림조합", "key": "UC3-000004", "name": "둘째 자리"},
]
person = _node("holding([{키: 'UP15-000001', 담은로직: [{로직: 'K'}]}], 'K', 'personal', '나')")
assert person == [{"kind": "combo", "where": "개인 · 나", "key": "UP15-000001", "name": ""}]
# ── 쉬운 말 ─────────────────────────────────────────────────────────────
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_plainError_상태와_검사글을_쉬운_말로() -> None: