1129 lines
44 KiB
JavaScript
1129 lines
44 KiB
JavaScript
(function (root, factory) {
|
|
"use strict";
|
|
|
|
const workflow = typeof module === "object" && module.exports
|
|
? require("./manual-financial-workflow.js")
|
|
: root && root.MbnManualFinancialWorkflow;
|
|
const api = Object.freeze(factory(workflow, root));
|
|
if (typeof module === "object" && module.exports) module.exports = api;
|
|
if (root) root.MbnManualFinancialUi = api;
|
|
})(typeof globalThis === "object" ? globalThis : this, function (workflow, root) {
|
|
"use strict";
|
|
|
|
if (!workflow || !workflow.bridgeContract) {
|
|
throw new Error("MbnManualFinancialWorkflow must be loaded before manual-financial-ui.js.");
|
|
}
|
|
|
|
const contract = workflow.bridgeContract;
|
|
const STOCK_SEARCH_REQUEST = "search-stocks";
|
|
const STOCK_SEARCH_RESULT = "stock-search-results";
|
|
const STOCK_SEARCH_ERROR = "stock-search-error";
|
|
const STOCK_SEARCH_MAXIMUM_QUERY_LENGTH = 64;
|
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
|
const UNSAFE_TEXT_PATTERN = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
|
const LEGACY_ACTION_SCREENS = Object.freeze({
|
|
"manual-revenue-mix": "revenue-composition",
|
|
"manual-growth-metrics": "growth-metrics",
|
|
"manual-sales": "sales",
|
|
"manual-operating-profit": "operating-profit"
|
|
});
|
|
const SCREEN_LABELS = Object.freeze({
|
|
"revenue-composition": "주요매출 구성",
|
|
"growth-metrics": "성장성 지표",
|
|
sales: "매출액",
|
|
"operating-profit": "영업이익"
|
|
});
|
|
const GROWTH_SERIES = Object.freeze([
|
|
["salesGrowth", "매출액 증가율"],
|
|
["operatingProfitGrowth", "영업이익 증가율"],
|
|
["netAssetGrowth", "순자산 증가율"],
|
|
["netIncomeGrowth", "순이익 증가율"]
|
|
]);
|
|
|
|
let processWriteQuarantine = null;
|
|
let controllerOrdinal = 0;
|
|
|
|
function hasExactKeys(value, expectedKeys) {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
const actual = Object.keys(value).sort();
|
|
const expected = [...expectedKeys].sort();
|
|
return actual.length === expected.length &&
|
|
actual.every((key, index) => key === expected[index]);
|
|
}
|
|
|
|
function safeText(value, maximumLength, allowEmpty = false) {
|
|
return typeof value === "string" && value === value.trim() &&
|
|
value.length <= maximumLength && (allowEmpty || value.length > 0) &&
|
|
!UNSAFE_TEXT_PATTERN.test(value);
|
|
}
|
|
|
|
function textValue(value) {
|
|
return value === undefined || value === null ? "" : String(value).trim();
|
|
}
|
|
|
|
function freezeObject(value) {
|
|
return Object.freeze({ ...value });
|
|
}
|
|
|
|
function resolveScreen(value) {
|
|
if (typeof value === "string") {
|
|
if (workflow.getScreenDefinition(value)) return value;
|
|
return LEGACY_ACTION_SCREENS[value] || null;
|
|
}
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
if (typeof value.screen === "string" && workflow.getScreenDefinition(value.screen)) {
|
|
return value.screen;
|
|
}
|
|
return typeof value.id === "string" ? LEGACY_ACTION_SCREENS[value.id] || null : null;
|
|
}
|
|
|
|
function emptyForm(screen, stockName = "") {
|
|
if (!workflow.getScreenDefinition(screen)) return null;
|
|
const result = { stockName: textValue(stockName) };
|
|
if (screen === "revenue-composition") {
|
|
result.baseDate = "";
|
|
for (let index = 1; index <= 5; index += 1) {
|
|
result[`slice${index}Label`] = "";
|
|
result[`slice${index}Percentage`] = "";
|
|
}
|
|
} else if (screen === "growth-metrics") {
|
|
for (let index = 1; index <= 4; index += 1) {
|
|
result[`period${index}`] = "";
|
|
for (const [series] of GROWTH_SERIES) result[`${series}${index}`] = "";
|
|
}
|
|
} else {
|
|
for (let index = 1; index <= 6; index += 1) {
|
|
result[`quarter${index}`] = "";
|
|
result[`value${index}`] = "";
|
|
}
|
|
}
|
|
return freezeObject(result);
|
|
}
|
|
|
|
function recordToForm(screen, value) {
|
|
const record = workflow.normalizeRecord(screen, value);
|
|
if (!record) return null;
|
|
const result = { stockName: record.stockName };
|
|
if (screen === "revenue-composition") {
|
|
result.baseDate = record.baseDate;
|
|
record.slices.forEach((slice, index) => {
|
|
result[`slice${index + 1}Label`] = slice ? slice.label : "";
|
|
result[`slice${index + 1}Percentage`] = slice ? slice.percentageText : "";
|
|
});
|
|
} else if (screen === "growth-metrics") {
|
|
record.periods.forEach((period, index) => {
|
|
result[`period${index + 1}`] = period;
|
|
});
|
|
for (const [series] of GROWTH_SERIES) {
|
|
record[series].forEach((item, index) => {
|
|
result[`${series}${index + 1}`] = item === null ? "" : String(item);
|
|
});
|
|
}
|
|
} else {
|
|
record.quarters.forEach((item, index) => {
|
|
result[`quarter${index + 1}`] = item.quarter;
|
|
result[`value${index + 1}`] = String(item.value);
|
|
});
|
|
}
|
|
return freezeObject(result);
|
|
}
|
|
|
|
function optionalNumber(value) {
|
|
const raw = textValue(value);
|
|
if (raw === "") return null;
|
|
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/.test(raw)) return undefined;
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function requiredInteger(value) {
|
|
const raw = textValue(value);
|
|
if (!/^[+-]?\d+$/.test(raw)) return undefined;
|
|
const parsed = Number(raw);
|
|
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
}
|
|
|
|
function formToRecord(screen, form) {
|
|
if (!workflow.getScreenDefinition(screen) ||
|
|
!form || typeof form !== "object" || Array.isArray(form)) return null;
|
|
const stockName = textValue(form.stockName);
|
|
let candidate;
|
|
if (screen === "revenue-composition") {
|
|
const slices = [];
|
|
for (let index = 1; index <= 5; index += 1) {
|
|
const label = textValue(form[`slice${index}Label`]);
|
|
const percentageText = textValue(form[`slice${index}Percentage`]);
|
|
if (!label && !percentageText) {
|
|
slices.push(null);
|
|
continue;
|
|
}
|
|
const percentage = percentageText === "-" ? 0 : optionalNumber(percentageText);
|
|
if (!label || !percentageText || percentage === undefined || percentage === null) return null;
|
|
slices.push({ label, percentageText, percentage });
|
|
}
|
|
candidate = {
|
|
kind: screen,
|
|
stockName,
|
|
baseDate: textValue(form.baseDate),
|
|
slices
|
|
};
|
|
} else if (screen === "growth-metrics") {
|
|
candidate = { kind: screen, stockName };
|
|
for (const [series] of GROWTH_SERIES) {
|
|
const values = [];
|
|
for (let index = 1; index <= 4; index += 1) {
|
|
const parsed = optionalNumber(form[`${series}${index}`]);
|
|
if (parsed === undefined) return null;
|
|
values.push(parsed);
|
|
}
|
|
candidate[series] = values;
|
|
}
|
|
candidate.periods = [];
|
|
for (let index = 1; index <= 4; index += 1) {
|
|
candidate.periods.push(textValue(form[`period${index}`]));
|
|
}
|
|
} else {
|
|
const quarters = [];
|
|
for (let index = 1; index <= 6; index += 1) {
|
|
const quarter = textValue(form[`quarter${index}`]);
|
|
const value = requiredInteger(form[`value${index}`]);
|
|
if (!quarter || value === undefined) return null;
|
|
quarters.push({ quarter, value });
|
|
}
|
|
candidate = { kind: screen, stockName, quarters };
|
|
}
|
|
return workflow.normalizeRecord(screen, candidate);
|
|
}
|
|
|
|
function latchWriteQuarantine(message) {
|
|
if (!processWriteQuarantine) {
|
|
processWriteQuarantine = freezeObject({
|
|
active: true,
|
|
message: safeText(message, 512)
|
|
? message
|
|
: "수동 재무 DB 쓰기 결과를 확인할 수 없습니다. 앱을 다시 시작하고 DB를 대조하세요."
|
|
});
|
|
}
|
|
return processWriteQuarantine;
|
|
}
|
|
|
|
function getProcessWriteQuarantine() {
|
|
return processWriteQuarantine || Object.freeze({ active: false, message: "" });
|
|
}
|
|
|
|
function createPendingCoordinator() {
|
|
const slots = Object.create(null);
|
|
|
|
function set(slot, request, context = {}) {
|
|
if (typeof slot !== "string" || !request || !REQUEST_ID_PATTERN.test(request.requestId)) {
|
|
throw new TypeError("A correlated request is required.");
|
|
}
|
|
slots[slot] = Object.freeze({ request, context: Object.freeze({ ...context }) });
|
|
return slots[slot];
|
|
}
|
|
|
|
function get(slot) {
|
|
return slots[slot] || null;
|
|
}
|
|
|
|
function matches(slot, requestId) {
|
|
const pending = get(slot);
|
|
return !!pending && pending.request.requestId === requestId;
|
|
}
|
|
|
|
function clear(slot, requestId) {
|
|
if (!Object.prototype.hasOwnProperty.call(slots, slot)) return false;
|
|
if (requestId !== undefined && slots[slot].request.requestId !== requestId) return false;
|
|
delete slots[slot];
|
|
return true;
|
|
}
|
|
|
|
function clearReads() {
|
|
for (const slot of ["list", "load", "stock", "selection"]) delete slots[slot];
|
|
}
|
|
|
|
function snapshot() {
|
|
const result = {};
|
|
for (const [slot, pending] of Object.entries(slots)) {
|
|
result[slot] = pending.request.requestId;
|
|
}
|
|
return Object.freeze(result);
|
|
}
|
|
|
|
function acceptMutation(kind, payload) {
|
|
const pending = get("write");
|
|
if (!pending) return Object.freeze({ status: "stale", value: null, pending: null });
|
|
const requestId = payload && typeof payload === "object" ? payload.requestId : null;
|
|
if (typeof requestId === "string" && REQUEST_ID_PATTERN.test(requestId) &&
|
|
requestId !== pending.request.requestId) {
|
|
return Object.freeze({ status: "stale", value: null, pending: null });
|
|
}
|
|
const value = kind === "result"
|
|
? workflow.normalizeMutationResult(payload, pending.request)
|
|
: workflow.normalizeMutationError(payload, pending.request);
|
|
clear("write");
|
|
if (!value) {
|
|
const quarantine = latchWriteQuarantine(
|
|
"수동 재무 DB 쓰기 응답의 상관관계 또는 결과 형식을 확인할 수 없습니다.");
|
|
return Object.freeze({ status: "invalid", value: null, pending, quarantine });
|
|
}
|
|
if (kind === "error" && value.outcomeUnknown) {
|
|
latchWriteQuarantine(value.message);
|
|
}
|
|
return Object.freeze({ status: "accepted", value, pending });
|
|
}
|
|
|
|
return Object.freeze({
|
|
set,
|
|
get,
|
|
matches,
|
|
clear,
|
|
clearReads,
|
|
snapshot,
|
|
acceptMutationResult: payload => acceptMutation("result", payload),
|
|
acceptMutationError: payload => acceptMutation("error", payload)
|
|
});
|
|
}
|
|
|
|
function normalizeStockSearchError(value, expectedRequest) {
|
|
if (!hasExactKeys(value, ["requestId", "query", "message"]) ||
|
|
!REQUEST_ID_PATTERN.test(value.requestId) ||
|
|
!safeText(value.query, contract.maximumQueryLength, true) ||
|
|
!safeText(value.message, 512) || !expectedRequest ||
|
|
value.requestId !== expectedRequest.requestId ||
|
|
value.query !== expectedRequest.query) return null;
|
|
return freezeObject(value);
|
|
}
|
|
|
|
function preferredStockIdentity(raw, response, stockName) {
|
|
const exact = response.results.filter(item => item.name === stockName);
|
|
if (exact.length !== 1) return null;
|
|
const only = exact[0];
|
|
if (raw && typeof raw === "object" && !Array.isArray(raw) &&
|
|
raw.name === only.name && raw.market === only.market && raw.code === only.code &&
|
|
(raw.source === undefined || raw.source === only.source)) return only;
|
|
return only;
|
|
}
|
|
|
|
function createController(options) {
|
|
if (!options || typeof options !== "object" || Array.isArray(options)) {
|
|
throw new TypeError("Manual-financial UI dependencies are required.");
|
|
}
|
|
const required = [
|
|
"postNative", "appendPlaylistEntry", "isLocked", "getSelectedStock",
|
|
"showToast", "addLog", "createId"
|
|
];
|
|
for (const name of required) {
|
|
if (typeof options[name] !== "function") {
|
|
throw new TypeError(`Manual-financial UI dependency ${name} must be a function.`);
|
|
}
|
|
}
|
|
|
|
const pending = createPendingCoordinator();
|
|
const state = {
|
|
mounted: false,
|
|
visible: false,
|
|
screen: null,
|
|
profile: null,
|
|
query: "",
|
|
status: "idle",
|
|
message: "",
|
|
items: [],
|
|
snapshot: null,
|
|
form: null,
|
|
stockStatus: "idle",
|
|
verifiedStock: null,
|
|
selectedStockIdentity: null,
|
|
locked: false
|
|
};
|
|
const refs = Object.create(null);
|
|
const instanceId = `manual-financial-${++controllerOrdinal}`;
|
|
|
|
function externallyLocked() {
|
|
let applicationLocked = true;
|
|
try {
|
|
applicationLocked = options.isLocked() !== false;
|
|
} catch {
|
|
applicationLocked = true;
|
|
}
|
|
return state.locked || applicationLocked;
|
|
}
|
|
|
|
function writeBlocked() {
|
|
return externallyLocked() || !!pending.get("write") || !!pending.get("load") ||
|
|
!!pending.get("stock") || !!pending.get("selection") ||
|
|
getProcessWriteQuarantine().active;
|
|
}
|
|
|
|
function notify(message) {
|
|
if (safeText(message, 512)) options.showToast(message);
|
|
}
|
|
|
|
function log(message) {
|
|
if (safeText(message, 512)) options.addLog(message);
|
|
}
|
|
|
|
function nextRequestId() {
|
|
const value = String(options.createId());
|
|
if (!REQUEST_ID_PATTERN.test(value)) {
|
|
throw new TypeError("createId must return a safe unique request id.");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function post(type, payload) {
|
|
options.postNative(type, payload);
|
|
}
|
|
|
|
function seedStockName() {
|
|
try {
|
|
const stock = options.getSelectedStock();
|
|
return stock && typeof stock.name === "string" ? stock.name : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function stateView() {
|
|
const quarantine = getProcessWriteQuarantine();
|
|
return Object.freeze({
|
|
mounted: state.mounted,
|
|
visible: state.visible,
|
|
screen: state.screen,
|
|
status: state.status,
|
|
message: state.message,
|
|
query: state.query,
|
|
itemCount: state.items.length,
|
|
selectedStockName: state.snapshot?.stockName || state.form?.stockName || "",
|
|
hasSnapshot: !!state.snapshot,
|
|
stockStatus: state.stockStatus,
|
|
verifiedStock: state.verifiedStock
|
|
? freezeObject(state.verifiedStock)
|
|
: null,
|
|
pending: pending.snapshot(),
|
|
locked: externallyLocked(),
|
|
writeQuarantined: quarantine.active,
|
|
quarantineMessage: quarantine.message,
|
|
canWrite: !writeBlocked()
|
|
});
|
|
}
|
|
|
|
function setStatus(status, message = "") {
|
|
state.status = status;
|
|
state.message = safeText(message, 512, true) ? message : "";
|
|
}
|
|
|
|
function requestList(query = state.query) {
|
|
if (!state.screen) return false;
|
|
if (pending.get("write")) {
|
|
notify("DB 쓰기 결과를 기다리는 동안 목록을 다시 조회할 수 없습니다.");
|
|
return false;
|
|
}
|
|
try {
|
|
const request = workflow.createListRequest(
|
|
nextRequestId(), state.screen, textValue(query), contract.defaultMaximumResults);
|
|
state.query = request.query;
|
|
pending.set("list", request);
|
|
setStatus("loading", "수동 재무 목록을 조회하고 있습니다.");
|
|
post(contract.listRequestType, request);
|
|
render();
|
|
return true;
|
|
} catch {
|
|
setStatus("error", "목록 검색 조건이 올바르지 않습니다.");
|
|
render();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function requestLoad(stockName) {
|
|
if (!state.screen) return false;
|
|
if (pending.get("write")) {
|
|
notify("DB 쓰기 결과를 기다리는 동안 행을 다시 조회할 수 없습니다.");
|
|
return false;
|
|
}
|
|
try {
|
|
const request = workflow.createLoadRequest(nextRequestId(), state.screen, stockName);
|
|
pending.set("load", request);
|
|
setStatus("loading", `${stockName} 데이터를 다시 확인하고 있습니다.`);
|
|
post(contract.loadRequestType, request);
|
|
render();
|
|
return true;
|
|
} catch {
|
|
setStatus("error", "선택한 수동 재무 행을 조회할 수 없습니다.");
|
|
render();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function syncFormFromDom() {
|
|
if (!state.form || !refs.formInputs) return;
|
|
const next = { ...state.form };
|
|
for (const [key, input] of refs.formInputs.entries()) next[key] = input.value;
|
|
state.form = freezeObject(next);
|
|
}
|
|
|
|
function currentRecord() {
|
|
syncFormFromDom();
|
|
return formToRecord(state.screen, state.form);
|
|
}
|
|
|
|
function beginStockVerification(intent) {
|
|
if (intent === "verify-only" &&
|
|
(pending.get("write") || pending.get("load") || pending.get("selection"))) {
|
|
notify("진행 중인 GraphE 요청이 끝난 뒤 종목을 다시 확인하세요.");
|
|
return false;
|
|
}
|
|
if (!state.screen || ["create", "update", "delete", "playlist"].includes(intent) &&
|
|
writeBlocked()) {
|
|
notify(getProcessWriteQuarantine().active
|
|
? getProcessWriteQuarantine().message
|
|
: "송출 또는 다른 쓰기 작업 중에는 수동 재무 데이터를 변경할 수 없습니다.");
|
|
return false;
|
|
}
|
|
if ((intent === "update" || intent === "delete" || intent === "playlist") && !state.snapshot) {
|
|
notify("DB 목록에서 먼저 행을 선택하세요.");
|
|
return false;
|
|
}
|
|
const record = currentRecord();
|
|
if (!record) {
|
|
notify("화면 입력값과 필수 개수를 확인하세요.");
|
|
return false;
|
|
}
|
|
if (state.snapshot && record.stockName !== state.snapshot.stockName) {
|
|
notify("기존 행의 종목명은 변경할 수 없습니다.");
|
|
return false;
|
|
}
|
|
if (record.stockName.length > STOCK_SEARCH_MAXIMUM_QUERY_LENGTH) {
|
|
notify(`종목명은 재검증을 위해 ${STOCK_SEARCH_MAXIMUM_QUERY_LENGTH}자 이하여야 합니다.`);
|
|
return false;
|
|
}
|
|
let preferred = null;
|
|
try {
|
|
preferred = options.getSelectedStock();
|
|
} catch {
|
|
preferred = null;
|
|
}
|
|
let request;
|
|
try {
|
|
request = Object.freeze({
|
|
requestId: nextRequestId(),
|
|
query: record.stockName,
|
|
maximumResults: contract.defaultMaximumResults
|
|
});
|
|
} catch {
|
|
notify("종목 검색 요청 ID를 만들 수 없습니다.");
|
|
return false;
|
|
}
|
|
pending.set("stock", request, {
|
|
intent,
|
|
record,
|
|
snapshot: state.snapshot,
|
|
preferred
|
|
});
|
|
state.verifiedStock = null;
|
|
state.stockStatus = "loading";
|
|
setStatus("loading", `${record.stockName} 종목 identity를 DB에서 다시 확인하고 있습니다.`);
|
|
post(STOCK_SEARCH_REQUEST, request);
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
function postMutation(operation, record, snapshot) {
|
|
if (writeBlocked()) return false;
|
|
let request;
|
|
let type;
|
|
try {
|
|
if (operation === "create") {
|
|
request = workflow.createCreateRequest(nextRequestId(), record, state.verifiedStock);
|
|
type = contract.createRequestType;
|
|
} else if (operation === "update") {
|
|
request = workflow.createUpdateRequest(nextRequestId(), snapshot, record);
|
|
type = contract.updateRequestType;
|
|
} else if (operation === "delete-one") {
|
|
request = workflow.createDeleteRequest(nextRequestId(), snapshot);
|
|
type = contract.deleteRequestType;
|
|
} else {
|
|
return false;
|
|
}
|
|
} catch {
|
|
notify("DB 쓰기 요청을 안전하게 만들 수 없습니다.");
|
|
return false;
|
|
}
|
|
pending.set("write", request, { operation });
|
|
setStatus("writing", "DB 쓰기 결과를 기다리고 있습니다. 자동 재시도하지 않습니다.");
|
|
post(type, request);
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
function postSelection(snapshot, verifiedStock) {
|
|
if (externallyLocked() || !snapshot || snapshot !== state.snapshot) return false;
|
|
try {
|
|
const request = workflow.createSelectionRequest(nextRequestId(), snapshot, verifiedStock);
|
|
pending.set("selection", request, { snapshot, verifiedStock });
|
|
setStatus("loading", "플레이리스트 장면 identity를 네이티브에서 확인하고 있습니다.");
|
|
post(contract.selectionRequestType, request);
|
|
render();
|
|
return true;
|
|
} catch {
|
|
notify("플레이리스트 선택 요청을 만들 수 없습니다.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function continueAfterStockVerification(context, verifiedStock) {
|
|
state.verifiedStock = verifiedStock;
|
|
state.selectedStockIdentity = freezeObject(verifiedStock);
|
|
state.stockStatus = "verified";
|
|
if (context.intent === "verify-only") {
|
|
setStatus("ready", `${verifiedStock.name} 종목 identity를 확인했습니다.`);
|
|
notify("종목 identity를 DB에서 확인했습니다.");
|
|
render();
|
|
return true;
|
|
}
|
|
if (context.snapshot !== state.snapshot) {
|
|
setStatus("error", "행 선택이 변경되어 작업을 중단했습니다.");
|
|
render();
|
|
return false;
|
|
}
|
|
if (context.intent === "create") return postMutation("create", context.record, null);
|
|
if (context.intent === "update") return postMutation("update", context.record, context.snapshot);
|
|
if (context.intent === "delete") return postMutation("delete-one", context.record, context.snapshot);
|
|
if (context.intent === "playlist") return postSelection(context.snapshot, verifiedStock);
|
|
return false;
|
|
}
|
|
|
|
function deleteAll() {
|
|
if (!state.screen || writeBlocked()) {
|
|
notify(getProcessWriteQuarantine().active
|
|
? getProcessWriteQuarantine().message
|
|
: "현재는 전체 삭제를 실행할 수 없습니다.");
|
|
return false;
|
|
}
|
|
const profile = workflow.getScreenDefinition(state.screen);
|
|
const confirmFunction = root && typeof root.confirm === "function" ? root.confirm.bind(root) : null;
|
|
if (!confirmFunction || !confirmFunction(`${SCREEN_LABELS[state.screen]} 전체 행을 삭제할까요?`)) {
|
|
return false;
|
|
}
|
|
try {
|
|
const request = workflow.createDeleteAllRequest(
|
|
nextRequestId(), state.screen, profile.deleteAllConfirmation);
|
|
pending.set("write", request, { operation: "delete-all" });
|
|
setStatus("writing", "전체 삭제 결과를 기다리고 있습니다. 자동 재시도하지 않습니다.");
|
|
post(contract.deleteAllRequestType, request);
|
|
render();
|
|
return true;
|
|
} catch {
|
|
notify("전체 삭제 요청을 안전하게 만들 수 없습니다.");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function consumeRead(slot, payload, normalizer, onAccepted) {
|
|
const requestId = payload && typeof payload === "object" ? payload.requestId : null;
|
|
const active = pending.get(slot);
|
|
if (!active || active.request.requestId !== requestId) return false;
|
|
const normalized = normalizer(payload, active.request);
|
|
pending.clear(slot);
|
|
if (!normalized) {
|
|
setStatus("error", "네이티브 응답 형식 또는 상관관계가 올바르지 않습니다.");
|
|
render();
|
|
return true;
|
|
}
|
|
onAccepted(normalized, active);
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
function handleListResult(payload) {
|
|
return consumeRead("list", payload, workflow.normalizeListResponse, normalized => {
|
|
state.items = [...normalized.items];
|
|
setStatus("ready", `${normalized.totalRowCount}개 행을 조회했습니다.`);
|
|
});
|
|
}
|
|
|
|
function handleLoadResult(payload) {
|
|
return consumeRead("load", payload, workflow.normalizeLoadResponse, normalized => {
|
|
state.snapshot = normalized.snapshot;
|
|
state.form = recordToForm(state.screen, normalized.snapshot.record);
|
|
state.verifiedStock = null;
|
|
state.stockStatus = "idle";
|
|
setStatus("ready", `${normalized.snapshot.stockName} 행을 불러왔습니다.`);
|
|
});
|
|
}
|
|
|
|
function handleStockResult(payload) {
|
|
const active = pending.get("stock");
|
|
const requestId = payload && typeof payload === "object" ? payload.requestId : null;
|
|
if (!active || requestId !== active.request.requestId) return false;
|
|
const normalized = workflow.normalizeStockSearchResponse(payload);
|
|
pending.clear("stock");
|
|
if (!normalized || normalized.query !== active.request.query || normalized.truncated ||
|
|
normalized.totalRowCount > active.request.maximumResults) {
|
|
state.stockStatus = "error";
|
|
setStatus("error", "종목 검색 응답 형식 또는 상관관계가 올바르지 않습니다.");
|
|
render();
|
|
return true;
|
|
}
|
|
const identity = preferredStockIdentity(
|
|
active.context.preferred,
|
|
normalized,
|
|
active.context.record.stockName);
|
|
const verified = identity && workflow.verifyStockForRecord(
|
|
active.context.record,
|
|
normalized,
|
|
identity.market,
|
|
identity.code);
|
|
if (!verified) {
|
|
state.stockStatus = "error";
|
|
setStatus("error", "같은 이름의 종목이 없거나 시장 identity가 모호합니다.");
|
|
render();
|
|
return true;
|
|
}
|
|
continueAfterStockVerification(active.context, verified);
|
|
return true;
|
|
}
|
|
|
|
function handleSelectionResult(payload) {
|
|
return consumeRead("selection", payload, workflow.normalizeSelectionResponse,
|
|
(normalized, active) => {
|
|
try {
|
|
const entry = workflow.createPlaylistEntry(
|
|
active.context.snapshot,
|
|
active.context.verifiedStock,
|
|
normalized,
|
|
{ id: nextRequestId(), fadeDuration: 6 });
|
|
if (!workflow.isPlaylistEntryPlayable(entry)) throw new TypeError("Untrusted entry.");
|
|
if (options.appendPlaylistEntry(entry) === false) {
|
|
throw new Error("The host rejected the playlist entry.");
|
|
}
|
|
setStatus("ready", `${entry.title} ${SCREEN_LABELS[state.screen]} 항목을 추가했습니다.`);
|
|
notify("플레이리스트에 추가했습니다.");
|
|
log(`GraphE playlist add · ${entry.code} · ${entry.title}`);
|
|
} catch {
|
|
setStatus("error", "네이티브 확인 결과로 플레이리스트 항목을 만들 수 없습니다.");
|
|
}
|
|
});
|
|
}
|
|
|
|
function handleReadError(slot, payload, normalizer) {
|
|
const active = pending.get(slot);
|
|
const requestId = payload && typeof payload === "object" ? payload.requestId : null;
|
|
if (!active || requestId !== active.request.requestId) return false;
|
|
const normalized = normalizer(payload, active.request);
|
|
pending.clear(slot);
|
|
setStatus("error", normalized?.message || "네이티브 오류 응답 형식이 올바르지 않습니다.");
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
function handleStockError(payload) {
|
|
const active = pending.get("stock");
|
|
const requestId = payload && typeof payload === "object" ? payload.requestId : null;
|
|
if (!active || requestId !== active.request.requestId) return false;
|
|
const normalized = normalizeStockSearchError(payload, active.request);
|
|
pending.clear("stock");
|
|
state.stockStatus = "error";
|
|
setStatus("error", normalized?.message || "종목 검색 오류 응답 형식이 올바르지 않습니다.");
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
function handleMutation(kind, payload) {
|
|
const outcome = kind === "result"
|
|
? pending.acceptMutationResult(payload)
|
|
: pending.acceptMutationError(payload);
|
|
if (outcome.status === "stale") return false;
|
|
if (outcome.status === "invalid") {
|
|
setStatus("quarantined", getProcessWriteQuarantine().message);
|
|
notify(getProcessWriteQuarantine().message);
|
|
render();
|
|
return true;
|
|
}
|
|
if (kind === "error") {
|
|
setStatus(outcome.value.outcomeUnknown ? "quarantined" : "error", outcome.value.message);
|
|
notify(outcome.value.message);
|
|
render();
|
|
return true;
|
|
}
|
|
const result = outcome.value;
|
|
log(`GraphE DB ${result.operation} committed · ${result.screen} · ${result.affectedRows}`);
|
|
notify("수동 재무 DB 변경이 커밋되었습니다.");
|
|
if (result.operation === "delete-one" || result.operation === "delete-all") {
|
|
state.snapshot = null;
|
|
state.form = emptyForm(state.screen, seedStockName());
|
|
state.verifiedStock = null;
|
|
}
|
|
setStatus("ready", "DB 변경을 확인했습니다.");
|
|
requestList(state.query);
|
|
if (result.operation === "create" || result.operation === "update") {
|
|
requestLoad(result.stockName);
|
|
}
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
function handleGenericRequestError(payload) {
|
|
const operation = payload && typeof payload === "object" ? payload.operation : null;
|
|
const slot = operation === "list" ? "list"
|
|
: operation === "load" ? "load"
|
|
: operation === "selection" ? "selection"
|
|
: ["create", "update", "delete-one", "delete-all"].includes(operation)
|
|
? "write"
|
|
: null;
|
|
const active = slot && pending.get(slot);
|
|
if (!active || payload.requestId !== active.request.requestId) return false;
|
|
const expectedOperation = slot === "write" ? active.context.operation : operation;
|
|
const normalized = workflow.normalizeRequestError(payload, expectedOperation);
|
|
pending.clear(slot);
|
|
if (slot === "write" && !normalized) {
|
|
latchWriteQuarantine(
|
|
"수동 재무 DB 쓰기 요청 오류의 상관관계를 확인할 수 없습니다.");
|
|
}
|
|
const quarantined = slot === "write" && !normalized;
|
|
setStatus(
|
|
quarantined ? "quarantined" : "error",
|
|
quarantined
|
|
? getProcessWriteQuarantine().message
|
|
: normalized?.message || "네이티브 요청 오류 형식이 올바르지 않습니다.");
|
|
render();
|
|
return true;
|
|
}
|
|
|
|
function handleMessage(type, payload) {
|
|
switch (type) {
|
|
case contract.listResultType:
|
|
return handleListResult(payload);
|
|
case contract.listErrorType:
|
|
return handleReadError("list", payload, workflow.normalizeListError);
|
|
case contract.loadResultType:
|
|
return handleLoadResult(payload);
|
|
case contract.loadErrorType:
|
|
return handleReadError("load", payload, workflow.normalizeLoadError);
|
|
case STOCK_SEARCH_RESULT:
|
|
return handleStockResult(payload);
|
|
case STOCK_SEARCH_ERROR:
|
|
return handleStockError(payload);
|
|
case contract.selectionResultType:
|
|
return handleSelectionResult(payload);
|
|
case contract.selectionErrorType:
|
|
return handleReadError("selection", payload, workflow.normalizeSelectionError);
|
|
case contract.mutationResultType:
|
|
return handleMutation("result", payload);
|
|
case contract.mutationErrorType:
|
|
return handleMutation("error", payload);
|
|
case contract.requestErrorType:
|
|
return handleGenericRequestError(payload);
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function makeElement(documentObject, tag, className, text) {
|
|
const element = documentObject.createElement(tag);
|
|
if (className) element.className = className;
|
|
if (text !== undefined) element.textContent = text;
|
|
return element;
|
|
}
|
|
|
|
function makeButton(documentObject, text, className, handler) {
|
|
const button = makeElement(documentObject, "button", className, text);
|
|
button.type = "button";
|
|
button.addEventListener("click", handler);
|
|
return button;
|
|
}
|
|
|
|
function mount(host = typeof document === "object" ? document.body : null) {
|
|
if (state.mounted) return refs.overlay;
|
|
if (!host || typeof host.appendChild !== "function") {
|
|
throw new TypeError("A DOM host is required to mount the manual-financial UI.");
|
|
}
|
|
const documentObject = host.ownerDocument || (root && root.document);
|
|
if (!documentObject || typeof documentObject.createElement !== "function") {
|
|
throw new TypeError("A DOM document is required to mount the manual-financial UI.");
|
|
}
|
|
|
|
const overlay = makeElement(documentObject, "div", "mfui-overlay");
|
|
overlay.hidden = true;
|
|
const dialog = makeElement(documentObject, "section", "mfui-dialog");
|
|
dialog.setAttribute("role", "dialog");
|
|
dialog.setAttribute("aria-modal", "true");
|
|
dialog.setAttribute("aria-labelledby", `${instanceId}-title`);
|
|
|
|
const header = makeElement(documentObject, "header", "mfui-header");
|
|
const heading = makeElement(documentObject, "div", "mfui-heading");
|
|
const title = makeElement(documentObject, "h2", "mfui-title", "수동 재무 입력");
|
|
title.id = `${instanceId}-title`;
|
|
const subtitle = makeElement(documentObject, "p", "mfui-subtitle", "GraphE DB 행을 안전하게 조회합니다.");
|
|
heading.append(title, subtitle);
|
|
const close = makeButton(documentObject, "닫기", "mfui-close", () => {
|
|
state.visible = false;
|
|
render();
|
|
});
|
|
header.append(heading, close);
|
|
|
|
const body = makeElement(documentObject, "div", "mfui-body");
|
|
const listPanel = makeElement(documentObject, "aside", "mfui-list-panel");
|
|
const searchForm = makeElement(documentObject, "form", "mfui-search");
|
|
const query = makeElement(documentObject, "input", "mfui-search-input");
|
|
query.type = "search";
|
|
query.maxLength = contract.maximumQueryLength;
|
|
query.placeholder = "저장 종목명 검색";
|
|
query.setAttribute("aria-label", "수동 재무 저장 종목 검색");
|
|
const searchButton = makeElement(documentObject, "button", "mfui-search-button", "조회");
|
|
searchButton.type = "submit";
|
|
searchForm.append(query, searchButton);
|
|
searchForm.addEventListener("submit", event => {
|
|
event.preventDefault();
|
|
requestList(query.value);
|
|
});
|
|
const listState = makeElement(documentObject, "p", "mfui-list-state", "목록을 열어 주세요.");
|
|
const list = makeElement(documentObject, "div", "mfui-list");
|
|
list.setAttribute("role", "listbox");
|
|
listPanel.append(searchForm, listState, list);
|
|
|
|
const editor = makeElement(documentObject, "article", "mfui-editor");
|
|
const editorTitle = makeElement(documentObject, "h3", "mfui-editor-title", "입력값");
|
|
const stockStatus = makeElement(documentObject, "p", "mfui-stock-state", "종목 identity 미확인");
|
|
const form = makeElement(documentObject, "div", "mfui-form");
|
|
const actions = makeElement(documentObject, "div", "mfui-actions");
|
|
const reset = makeButton(documentObject, "새 입력", "mfui-button", () => {
|
|
if (writeBlocked()) {
|
|
notify("진행 중인 GraphE 작업이 끝난 뒤 새 입력을 시작하세요.");
|
|
return;
|
|
}
|
|
state.snapshot = null;
|
|
state.form = emptyForm(state.screen, seedStockName());
|
|
state.verifiedStock = null;
|
|
state.stockStatus = "idle";
|
|
setStatus("ready", "새 수동 재무 행을 입력할 수 있습니다.");
|
|
render();
|
|
});
|
|
const verify = makeButton(documentObject, "종목 재검증", "mfui-button", () => beginStockVerification("verify-only"));
|
|
const create = makeButton(documentObject, "신규 저장", "mfui-button mfui-primary", () => beginStockVerification("create"));
|
|
const update = makeButton(documentObject, "수정 저장", "mfui-button", () => beginStockVerification("update"));
|
|
const remove = makeButton(documentObject, "선택 삭제", "mfui-button mfui-danger", () => beginStockVerification("delete"));
|
|
const removeAll = makeButton(documentObject, "전체 삭제", "mfui-button mfui-danger", deleteAll);
|
|
const playlist = makeButton(documentObject, "플레이리스트 추가", "mfui-button mfui-accent", () => beginStockVerification("playlist"));
|
|
actions.append(reset, verify, create, update, remove, removeAll, playlist);
|
|
editor.append(editorTitle, stockStatus, form, actions);
|
|
body.append(listPanel, editor);
|
|
|
|
const footer = makeElement(documentObject, "footer", "mfui-footer");
|
|
const status = makeElement(documentObject, "p", "mfui-status", "준비됨");
|
|
const quarantine = makeElement(documentObject, "p", "mfui-quarantine");
|
|
footer.append(status, quarantine);
|
|
dialog.append(header, body, footer);
|
|
overlay.append(dialog);
|
|
host.appendChild(overlay);
|
|
|
|
Object.assign(refs, {
|
|
overlay,
|
|
dialog,
|
|
title,
|
|
subtitle,
|
|
close,
|
|
query,
|
|
searchButton,
|
|
listState,
|
|
list,
|
|
editorTitle,
|
|
stockStatus,
|
|
form,
|
|
actions,
|
|
reset,
|
|
verify,
|
|
create,
|
|
update,
|
|
remove,
|
|
removeAll,
|
|
playlist,
|
|
status,
|
|
quarantine,
|
|
formInputs: new Map()
|
|
});
|
|
state.mounted = true;
|
|
render();
|
|
return overlay;
|
|
}
|
|
|
|
function appendField(container, labelText, key, options = {}) {
|
|
const documentObject = container.ownerDocument;
|
|
const field = makeElement(documentObject, "label", "mfui-field");
|
|
const label = makeElement(documentObject, "span", "mfui-field-label", labelText);
|
|
const input = makeElement(documentObject, "input", "mfui-input");
|
|
input.type = options.type || "text";
|
|
input.inputMode = options.inputMode || "text";
|
|
input.maxLength = options.maxLength || 200;
|
|
input.value = state.form?.[key] || "";
|
|
input.readOnly = options.readOnly === true;
|
|
input.addEventListener("input", () => {
|
|
state.form = freezeObject({ ...state.form, [key]: input.value });
|
|
if (key === "stockName") {
|
|
state.verifiedStock = null;
|
|
state.stockStatus = "idle";
|
|
}
|
|
});
|
|
field.append(label, input);
|
|
container.append(field);
|
|
refs.formInputs.set(key, input);
|
|
return input;
|
|
}
|
|
|
|
function appendGroup(titleText) {
|
|
const documentObject = refs.form.ownerDocument;
|
|
const group = makeElement(documentObject, "fieldset", "mfui-group");
|
|
const legend = makeElement(documentObject, "legend", "mfui-group-title", titleText);
|
|
group.append(legend);
|
|
refs.form.append(group);
|
|
return group;
|
|
}
|
|
|
|
function renderForm() {
|
|
if (!state.mounted) return;
|
|
refs.form.replaceChildren();
|
|
refs.formInputs = new Map();
|
|
if (!state.screen || !state.form) return;
|
|
appendField(refs.form, "종목명", "stockName", { readOnly: !!state.snapshot });
|
|
if (state.screen === "revenue-composition") {
|
|
appendField(refs.form, "기준일", "baseDate");
|
|
for (let index = 1; index <= 5; index += 1) {
|
|
const group = appendGroup(`구성 ${index}`);
|
|
appendField(group, "구성명", `slice${index}Label`);
|
|
appendField(group, "비율", `slice${index}Percentage`, { inputMode: "decimal", maxLength: 64 });
|
|
}
|
|
} else if (state.screen === "growth-metrics") {
|
|
const periods = appendGroup("분기");
|
|
for (let index = 1; index <= 4; index += 1) {
|
|
appendField(periods, `분기 ${index}`, `period${index}`);
|
|
}
|
|
for (const [series, label] of GROWTH_SERIES) {
|
|
const group = appendGroup(label);
|
|
for (let index = 1; index <= 4; index += 1) {
|
|
appendField(group, `값 ${index}`, `${series}${index}`, { inputMode: "decimal", maxLength: 64 });
|
|
}
|
|
}
|
|
} else {
|
|
for (let index = 1; index <= 6; index += 1) {
|
|
const group = appendGroup(`분기·금액 ${index}`);
|
|
appendField(group, "분기", `quarter${index}`);
|
|
appendField(group, "금액", `value${index}`, { inputMode: "numeric", maxLength: 16 });
|
|
}
|
|
}
|
|
}
|
|
|
|
function renderList() {
|
|
if (!state.mounted) return;
|
|
refs.list.replaceChildren();
|
|
const documentObject = refs.list.ownerDocument;
|
|
for (const snapshot of state.items) {
|
|
const row = makeButton(documentObject, snapshot.stockName, "mfui-list-row", () => {
|
|
requestLoad(snapshot.stockName);
|
|
});
|
|
row.setAttribute("role", "option");
|
|
row.setAttribute("aria-selected", state.snapshot?.stockName === snapshot.stockName ? "true" : "false");
|
|
refs.list.append(row);
|
|
}
|
|
if (!state.items.length) {
|
|
refs.list.append(makeElement(documentObject, "p", "mfui-empty", "조회된 행이 없습니다."));
|
|
}
|
|
}
|
|
|
|
function render() {
|
|
const view = stateView();
|
|
if (!state.mounted) return view;
|
|
refs.overlay.hidden = !state.visible;
|
|
refs.title.textContent = state.screen ? SCREEN_LABELS[state.screen] : "수동 재무 입력";
|
|
refs.subtitle.textContent = state.profile
|
|
? `${state.profile.table} · ${state.profile.builderKey}/${state.profile.code}`
|
|
: "GraphE DB 행을 안전하게 조회합니다.";
|
|
refs.query.value = state.query;
|
|
refs.listState.textContent = state.status === "loading" && pending.get("list")
|
|
? "목록 조회 중"
|
|
: `${state.items.length}개 행`;
|
|
refs.editorTitle.textContent = state.snapshot
|
|
? `${state.snapshot.stockName} 수정`
|
|
: "신규 입력";
|
|
refs.stockStatus.textContent = state.stockStatus === "verified" && state.verifiedStock
|
|
? `${state.verifiedStock.market} · ${state.verifiedStock.code} 확인됨`
|
|
: state.stockStatus === "loading"
|
|
? "종목 identity 확인 중"
|
|
: state.stockStatus === "error"
|
|
? "종목 identity 확인 실패"
|
|
: "종목 identity 미확인";
|
|
refs.status.textContent = state.message || state.status;
|
|
refs.status.dataset.status = state.status;
|
|
refs.quarantine.hidden = !view.writeQuarantined;
|
|
refs.quarantine.textContent = view.quarantineMessage;
|
|
const blocked = writeBlocked();
|
|
refs.searchButton.disabled = !!pending.get("list");
|
|
refs.verify.disabled = externallyLocked() || !!pending.get("stock") ||
|
|
!!pending.get("write") || !!pending.get("load") || !!pending.get("selection");
|
|
refs.reset.disabled = writeBlocked();
|
|
refs.create.disabled = blocked || !!state.snapshot;
|
|
refs.update.disabled = blocked || !state.snapshot;
|
|
refs.remove.disabled = blocked || !state.snapshot;
|
|
refs.removeAll.disabled = blocked;
|
|
refs.playlist.disabled = writeBlocked() || !state.snapshot;
|
|
renderList();
|
|
renderForm();
|
|
return view;
|
|
}
|
|
|
|
function open(value) {
|
|
if (pending.get("write")) {
|
|
notify("DB 쓰기 결과를 기다리는 동안 다른 GraphE 화면으로 전환할 수 없습니다.");
|
|
return false;
|
|
}
|
|
const screen = resolveScreen(value);
|
|
if (!screen) {
|
|
notify("지원하지 않는 수동 재무 화면입니다.");
|
|
return false;
|
|
}
|
|
pending.clearReads();
|
|
state.screen = screen;
|
|
state.profile = workflow.getScreenDefinition(screen);
|
|
state.visible = true;
|
|
state.query = "";
|
|
state.items = [];
|
|
state.snapshot = null;
|
|
state.form = emptyForm(screen, seedStockName());
|
|
state.stockStatus = "idle";
|
|
state.verifiedStock = null;
|
|
setStatus("idle", "수동 재무 목록을 조회합니다.");
|
|
render();
|
|
return requestList("");
|
|
}
|
|
|
|
function setLocked(value) {
|
|
if (typeof value !== "boolean") throw new TypeError("setLocked requires a boolean value.");
|
|
state.locked = value;
|
|
return render();
|
|
}
|
|
|
|
return Object.freeze({ mount, open, handleMessage, render, setLocked });
|
|
}
|
|
|
|
/**
|
|
* App integration hook (intentionally not wired here):
|
|
* 1. Load manual-financial-workflow.js, this file and manual-financial-ui.css.
|
|
* 2. Construct one controller with createController(...), then call mount(document.body).
|
|
* 3. Route the four legacy manual action clicks to controller.open(action).
|
|
* 4. Offer every WebView message to controller.handleMessage(type, payload); it returns false
|
|
* for unrelated/shared stock messages that do not match its active request.
|
|
* 5. Mirror playlist PREPARED/PROGRAM/command locks through controller.setLocked(boolean).
|
|
* The host must keep this controller alive while a native request is pending so correlation and
|
|
* the process-lifetime OutcomeUnknown quarantine cannot be lost.
|
|
*/
|
|
|
|
return {
|
|
createController,
|
|
resolveScreen,
|
|
emptyForm,
|
|
recordToForm,
|
|
formToRecord,
|
|
createPendingCoordinator,
|
|
getProcessWriteQuarantine
|
|
};
|
|
});
|