Files
MBN_STOCK_WEBVIEW/Web/named-manual-restore-workflow.js

205 lines
8.9 KiB
JavaScript

(function (root, factory) {
"use strict";
const manualLists = typeof module === "object" && module.exports
? require("./manual-lists-workflow.js")
: root.MbnManualListsWorkflow;
const manualFinancial = typeof module === "object" && module.exports
? require("./manual-financial-workflow.js")
: root.MbnManualFinancialWorkflow;
const api = Object.freeze(factory(manualLists, manualFinancial));
if (typeof module === "object" && module.exports) module.exports = api;
if (root) root.MbnNamedManualRestoreWorkflow = api;
})(typeof globalThis === "object" ? globalThis : this, function (manualLists, manualFinancial) {
"use strict";
if (!manualLists || !manualFinancial) throw new Error("Manual restore dependencies are unavailable.");
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
const versionPattern = /^[a-f0-9]{64}$/;
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
const pendingValues = new WeakSet();
const legacyAudienceByGroup = Object.freeze({
"개인 순매도 상위(수동)": "INDIVIDUAL",
"외국인 순매도 상위(수동)": "FOREIGN",
"기관 순매도 상위(수동)": "INSTITUTION"
});
const marketByGroup = Object.freeze({
KOSPI: "kospi", KOSDAQ: "kosdaq", NXT_KOSPI: "nxt-kospi", NXT_KOSDAQ: "nxt-kosdaq",
"코스피": "kospi", "코스닥": "kosdaq", "코스피_NXT": "nxt-kospi", "코스닥_NXT": "nxt-kosdaq"
});
const financialByGraphic = Object.freeze(Object.fromEntries(
manualFinancial.sourceContract.screens.flatMap(screen => [
[screen.graphicType, screen],
[screen.label, screen]
])));
function safeText(value, maximum, allowEmpty = false) {
return typeof value === "string" && value.length <= maximum &&
(allowEmpty || value.length > 0) && value === value.trim() &&
!value.includes("^") && !unsafeTextPattern.test(value);
}
function selection(raw) {
if (!raw || typeof raw !== "object" || Array.isArray(raw) ||
typeof raw.enabled !== "boolean" || !Number.isInteger(raw.itemIndex) || raw.itemIndex < 0 ||
!safeText(raw.groupCode, 256, true) || !safeText(raw.subject, 4096, true) ||
!safeText(raw.graphicType, 256, true) || !safeText(raw.subtype, 256, true) ||
!safeText(raw.dataCode, 1024, true)) return null;
return Object.freeze({
groupCode: raw.groupCode,
subject: raw.subject,
graphicType: raw.graphicType,
subtype: raw.subtype,
dataCode: raw.dataCode
});
}
function options(value) {
if (!value || typeof value !== "object" || Array.isArray(value) ||
!identifierPattern.test(value.id) || !Number.isInteger(value.fadeDuration) ||
value.fadeDuration < 0 || value.fadeDuration > 60) return null;
return Object.freeze({ id: value.id, fadeDuration: value.fadeDuration });
}
function exactHistoricalViNames(subject) {
if (!safeText(subject, 4096) || subject.startsWith("@MBN_VI_SNAPSHOT_")) return null;
const names = subject.split(",");
if (names.length < 1 || names.length > manualLists.maximumViItems ||
names.some(name => !safeText(name, 200) || name !== name.trim()) ||
new Set(names).size !== names.length) return null;
return Object.freeze(names);
}
function classify(raw, rawOptions) {
const selected = selection(raw);
const normalizedOptions = options(rawOptions);
if (!selected || !normalizedOptions) return null;
let descriptor = null;
const canonicalAudience = manualLists.normalizeAudience(selected.groupCode);
const legacyAudience = legacyAudienceByGroup[selected.groupCode] || null;
if ((canonicalAudience && selected.subject === "INDEX" &&
selected.graphicType === "MANUAL_NET_SELL" && selected.subtype === "MANUAL_NET_SELL") ||
(legacyAudience && selected.subject === "" &&
selected.graphicType === "순매도 상위" && selected.subtype === "순매도 상위")) {
if (selected.dataCode !== "") return null;
descriptor = {
kind: "net-sell",
audience: canonicalAudience || legacyAudience,
historical: Boolean(legacyAudience)
};
} else if (selected.groupCode === "PAGED_VI" && selected.graphicType === "INPUT_ORDER" &&
selected.subtype === "CURRENT") {
if (selected.subject === manualLists.viSnapshotSubject && versionPattern.test(selected.dataCode)) {
descriptor = { kind: "vi", expectedVersion: selected.dataCode, historicalNames: null };
} else if (selected.dataCode === "") {
const names = exactHistoricalViNames(selected.subject);
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
}
} else if (selected.groupCode === "" && selected.graphicType === "5단 표그래프" &&
selected.subtype === "VI 발동(수동)" && selected.dataCode === "") {
const names = exactHistoricalViNames(selected.subject);
if (names) descriptor = { kind: "vi", expectedVersion: null, historicalNames: names };
} else {
const profile = financialByGraphic[selected.graphicType];
const market = marketByGroup[selected.groupCode];
if (profile && market && safeText(selected.subject, 200) &&
selected.subtype === "" && selected.dataCode === "") {
descriptor = {
kind: "financial",
screen: profile.screen,
builderKey: profile.builderKey,
code: profile.code,
market,
stockName: selected.subject,
identity: Object.freeze({ screen: profile.screen, stockName: selected.subject })
};
}
}
if (!descriptor) return null;
const pending = Object.freeze({
...descriptor,
itemIndex: raw.itemIndex,
enabled: raw.enabled,
entry: Object.freeze({ id: normalizedOptions.id }),
fadeDuration: normalizedOptions.fadeDuration,
rawSelection: selected
});
pendingValues.add(pending);
return pending;
}
function createManualListReadRequest(requestId, pending) {
if (!pendingValues.has(pending) || !["net-sell", "vi"].includes(pending.kind)) {
throw new TypeError("A validated named manual-list restore is required.");
}
return pending.kind === "net-sell"
? manualLists.createNetSellReadRequest(requestId, pending.audience)
: manualLists.createViReadRequest(requestId);
}
function materializeManualList(pending, payload, requestEnvelope) {
if (!pendingValues.has(pending) || !requestEnvelope ||
requestEnvelope.type !== (pending.kind === "net-sell"
? manualLists.bridgeContract.netSellReadType
: manualLists.bridgeContract.viReadType)) return null;
let entry;
if (pending.kind === "net-sell") {
const response = manualLists.normalizeNetSellDataResponse(payload, requestEnvelope.payload);
if (!response) return null;
entry = manualLists.createNetSellPlaylistEntry(pending.audience, {
id: pending.entry.id,
fadeDuration: pending.fadeDuration
});
} else if (pending.kind === "vi") {
const response = manualLists.normalizeViDataResponse(payload, requestEnvelope.payload.requestId);
if (!response || response.itemCount < 1 ||
(pending.expectedVersion !== null && response.version !== pending.expectedVersion)) return null;
if (pending.historicalNames !== null) {
const names = response.items.map(item => item.name);
if (names.length !== pending.historicalNames.length ||
names.some((name, index) => name !== pending.historicalNames[index])) return null;
}
entry = manualLists.createViPlaylistEntry(response.items, response.version, {
id: pending.entry.id,
fadeDuration: pending.fadeDuration
});
}
return manualLists.restorePlaylistEntry({ ...entry, enabled: pending.enabled });
}
function isPending(value, kind) {
return pendingValues.has(value) && (kind === undefined || value.kind === kind);
}
function matchesMaterializedEntry(pending, entry) {
if (!pendingValues.has(pending) || !entry || entry.id !== pending.entry.id ||
entry.enabled !== pending.enabled || entry.fadeDuration !== pending.fadeDuration) return false;
if (pending.kind === "net-sell") {
const restored = manualLists.restorePlaylistEntry(entry);
return !!restored && restored.builderKey === "s5025" &&
restored.operator.kind === "net-sell" && restored.operator.audience === pending.audience;
}
if (pending.kind === "vi") {
const restored = manualLists.restorePlaylistEntry(entry);
return !!restored && restored.builderKey === "s5074" && restored.operator.kind === "vi";
}
if (pending.kind === "financial") {
return manualFinancial.isPlaylistEntryPlayable(entry) &&
entry.builderKey === pending.builderKey && entry.code === pending.code &&
entry.stockName === pending.stockName && entry.market === pending.market;
}
return false;
}
return {
classify,
createManualListReadRequest,
materializeManualList,
isPending,
matchesMaterializedEntry
};
});