Complete legacy operator UI and playout migration
This commit is contained in:
390
Web/manual-lists-workflow.js
Normal file
390
Web/manual-lists-workflow.js
Normal file
@@ -0,0 +1,390 @@
|
||||
(function (root, factory) {
|
||||
"use strict";
|
||||
|
||||
const api = Object.freeze(factory());
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.MbnManualListsWorkflow = api;
|
||||
})(typeof globalThis === "object" ? globalThis : this, function () {
|
||||
"use strict";
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
const MAXIMUM_VI_ITEMS = 100;
|
||||
const VI_PAGE_SIZE = 5;
|
||||
const MAXIMUM_PAGE_COUNT = 20;
|
||||
const VI_SNAPSHOT_SUBJECT = "@MBN_VI_SNAPSHOT_V1";
|
||||
const identifierPattern = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const rowVersionPattern = /^[a-f0-9]{64}$/;
|
||||
const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Cs}\p{Zl}\p{Zp}\uFFFD]/u;
|
||||
const audiences = Object.freeze(["INDIVIDUAL", "FOREIGN", "INSTITUTION"]);
|
||||
const audienceLabels = Object.freeze({
|
||||
INDIVIDUAL: "개인",
|
||||
FOREIGN: "외국인",
|
||||
INSTITUTION: "기관"
|
||||
});
|
||||
|
||||
const bridgeContract = Object.freeze({
|
||||
statusRequestType: "request-manual-operator-data-status",
|
||||
statusResultType: "manual-operator-data-status",
|
||||
netSellReadType: "request-manual-net-sell-data",
|
||||
netSellDataType: "manual-net-sell-data",
|
||||
netSellSaveType: "save-manual-net-sell-data",
|
||||
netSellSaveResultType: "manual-net-sell-save-result",
|
||||
viReadType: "request-vi-manual-list",
|
||||
viDataType: "vi-manual-list-data",
|
||||
viSaveType: "save-vi-manual-list",
|
||||
viSaveResultType: "vi-manual-list-save-result",
|
||||
errorType: "manual-operator-data-error"
|
||||
});
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||
const actual = Object.keys(value).sort();
|
||||
const expected = [...keys].sort();
|
||||
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isRequestId(value) {
|
||||
return typeof value === "string" && identifierPattern.test(value);
|
||||
}
|
||||
|
||||
function isRowVersion(value) {
|
||||
return typeof value === "string" && rowVersionPattern.test(value);
|
||||
}
|
||||
|
||||
function isSafeText(value, maximumLength, allowEmpty = true) {
|
||||
return typeof value === "string" &&
|
||||
(allowEmpty || value.length > 0) &&
|
||||
value.length <= maximumLength &&
|
||||
!value.includes("^") &&
|
||||
!unsafeTextPattern.test(value);
|
||||
}
|
||||
|
||||
function normalizeAudience(value) {
|
||||
return typeof value === "string" && audiences.includes(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeNetSellRow(value) {
|
||||
if (!hasExactKeys(value, ["leftName", "leftAmount", "rightName", "rightAmount"])) return null;
|
||||
const keys = ["leftName", "leftAmount", "rightName", "rightAmount"];
|
||||
if (!keys.every(key => isSafeText(value[key], 256))) return null;
|
||||
return Object.freeze({
|
||||
leftName: value.leftName,
|
||||
leftAmount: value.leftAmount,
|
||||
rightName: value.rightName,
|
||||
rightAmount: value.rightAmount
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeNetSellRows(value) {
|
||||
if (!Array.isArray(value) || value.length !== 5) return null;
|
||||
const rows = value.map(normalizeNetSellRow);
|
||||
return rows.every(Boolean) ? Object.freeze(rows) : null;
|
||||
}
|
||||
|
||||
function normalizeViItem(value) {
|
||||
if (!hasExactKeys(value, ["code", "name"]) ||
|
||||
!isSafeText(value.code, 64, false) ||
|
||||
!isSafeText(value.name, 200, false) ||
|
||||
value.code !== value.code.trim() || value.name !== value.name.trim() ||
|
||||
value.name.includes(",")) return null;
|
||||
return Object.freeze({ code: value.code, name: value.name });
|
||||
}
|
||||
|
||||
function normalizeViItems(value, allowEmpty = true) {
|
||||
if (!Array.isArray(value) || value.length > MAXIMUM_VI_ITEMS || (!allowEmpty && value.length === 0)) {
|
||||
return null;
|
||||
}
|
||||
const items = value.map(normalizeViItem);
|
||||
return items.every(Boolean) ? Object.freeze(items) : null;
|
||||
}
|
||||
|
||||
function request(type, requestId, extra = {}) {
|
||||
if (!isRequestId(requestId)) throw new TypeError("A safe manual-data request id is required.");
|
||||
return Object.freeze({ type, payload: Object.freeze({ requestId, ...extra }) });
|
||||
}
|
||||
|
||||
function createStatusRequest(requestId) {
|
||||
return request(bridgeContract.statusRequestType, requestId);
|
||||
}
|
||||
|
||||
function createNetSellReadRequest(requestId, audience) {
|
||||
const normalized = normalizeAudience(audience);
|
||||
if (!normalized) throw new RangeError("The manual net-sell audience is invalid.");
|
||||
return request(bridgeContract.netSellReadType, requestId, { audience: normalized });
|
||||
}
|
||||
|
||||
function createNetSellSaveRequest(requestId, audience, rows) {
|
||||
const normalizedAudience = normalizeAudience(audience);
|
||||
const normalizedRows = normalizeNetSellRows(rows);
|
||||
if (!normalizedAudience || !normalizedRows) throw new TypeError("Five valid manual net-sell rows are required.");
|
||||
return request(bridgeContract.netSellSaveType, requestId, {
|
||||
audience: normalizedAudience,
|
||||
rows: normalizedRows
|
||||
});
|
||||
}
|
||||
|
||||
function createViReadRequest(requestId) {
|
||||
return request(bridgeContract.viReadType, requestId);
|
||||
}
|
||||
|
||||
function createViSaveRequest(requestId, items, expectedVersion) {
|
||||
const normalized = normalizeViItems(items);
|
||||
if (!normalized || !isRowVersion(expectedVersion)) {
|
||||
throw new TypeError("A valid VI manual list and its last-read version are required.");
|
||||
}
|
||||
return request(bridgeContract.viSaveType, requestId, {
|
||||
expectedVersion,
|
||||
items: normalized
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeStatusResponse(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, ["requestId", "available", "writeQuarantined", "message"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
typeof value.available !== "boolean" ||
|
||||
typeof value.writeQuarantined !== "boolean" ||
|
||||
!(value.message === null || isSafeText(value.message, 512, false)) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeNetSellDataResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "audience", "rows"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
!normalizeAudience(value.audience) ||
|
||||
(expectedRequest &&
|
||||
(value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null;
|
||||
const rows = normalizeNetSellRows(value.rows);
|
||||
return rows && Object.freeze({ requestId: value.requestId, audience: value.audience, rows });
|
||||
}
|
||||
|
||||
function normalizeNetSellSaveResponse(value, expectedRequest) {
|
||||
if (!hasExactKeys(value, ["requestId", "audience", "saved", "recovered"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
!normalizeAudience(value.audience) ||
|
||||
value.saved !== true || typeof value.recovered !== "boolean" ||
|
||||
(expectedRequest &&
|
||||
(value.requestId !== expectedRequest.requestId || value.audience !== expectedRequest.audience))) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeViDataResponse(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, ["requestId", "itemCount", "pageCount", "items", "version"]) ||
|
||||
!isRequestId(value.requestId) ||
|
||||
!isRowVersion(value.version) ||
|
||||
!Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS ||
|
||||
!Number.isInteger(value.pageCount) || value.pageCount < 0 || value.pageCount > MAXIMUM_PAGE_COUNT ||
|
||||
value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
const items = normalizeViItems(value.items);
|
||||
if (!items || items.length !== value.itemCount) return null;
|
||||
return Object.freeze({
|
||||
requestId: value.requestId,
|
||||
itemCount: value.itemCount,
|
||||
pageCount: value.pageCount,
|
||||
items,
|
||||
version: value.version
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeViSaveResponse(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, [
|
||||
"requestId", "saved", "recovered", "persisted", "itemCount", "pageCount", "version"
|
||||
]) ||
|
||||
!isRequestId(value.requestId) || value.saved !== true || typeof value.recovered !== "boolean" ||
|
||||
typeof value.persisted !== "boolean" || !isRowVersion(value.version) ||
|
||||
!Number.isInteger(value.itemCount) || value.itemCount < 0 || value.itemCount > MAXIMUM_VI_ITEMS ||
|
||||
!Number.isInteger(value.pageCount) ||
|
||||
value.pageCount !== (value.itemCount === 0 ? 0 : Math.ceil(value.itemCount / VI_PAGE_SIZE)) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function normalizeError(value, expectedRequestId) {
|
||||
if (!hasExactKeys(value, ["requestId", "operation", "code", "message", "retryable", "outcomeUnknown"]) ||
|
||||
!(value.requestId === "" || isRequestId(value.requestId)) ||
|
||||
!isSafeText(value.operation, 64, false) ||
|
||||
!isSafeText(value.code, 64, false) ||
|
||||
!isSafeText(value.message, 512, false) ||
|
||||
typeof value.retryable !== "boolean" || typeof value.outcomeUnknown !== "boolean" ||
|
||||
(value.outcomeUnknown && value.retryable) ||
|
||||
(expectedRequestId !== undefined && value.requestId !== expectedRequestId)) return null;
|
||||
return Object.freeze({ ...value });
|
||||
}
|
||||
|
||||
function requireOptions(options) {
|
||||
if (!hasExactKeys(options, ["id", "fadeDuration"]) || !isRequestId(options.id) ||
|
||||
!Number.isInteger(options.fadeDuration) || options.fadeDuration < 0 || options.fadeDuration > 60) {
|
||||
throw new TypeError("Safe manual-list playlist options are required.");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function createNetSellPlaylistEntry(audience, options) {
|
||||
const normalizedAudience = normalizeAudience(audience);
|
||||
const normalizedOptions = requireOptions(options);
|
||||
if (!normalizedAudience) throw new RangeError("The manual net-sell audience is invalid.");
|
||||
const label = audienceLabels[normalizedAudience];
|
||||
const selection = Object.freeze({
|
||||
groupCode: normalizedAudience,
|
||||
subject: "INDEX",
|
||||
graphicType: "MANUAL_NET_SELL",
|
||||
subtype: "MANUAL_NET_SELL",
|
||||
dataCode: ""
|
||||
});
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: "s5025",
|
||||
code: "5025",
|
||||
aliases: ["5025"],
|
||||
title: `${label} 순매도 상위(수동)`,
|
||||
detail: "매매동향 · 검증된 수동 입력 5행",
|
||||
category: "market",
|
||||
market: "index",
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-manual-lists-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "net-sell",
|
||||
audience: normalizedAudience
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function createViPlaylistEntry(items, rowVersion, options) {
|
||||
const normalizedItems = normalizeViItems(items, false);
|
||||
const normalizedOptions = requireOptions(options);
|
||||
if (!normalizedItems || !isRowVersion(rowVersion)) {
|
||||
throw new TypeError("A versioned non-empty VI snapshot is required.");
|
||||
}
|
||||
const pageCount = Math.ceil(normalizedItems.length / VI_PAGE_SIZE);
|
||||
const selection = Object.freeze({
|
||||
groupCode: "PAGED_VI",
|
||||
subject: VI_SNAPSHOT_SUBJECT,
|
||||
graphicType: "INPUT_ORDER",
|
||||
subtype: "CURRENT",
|
||||
dataCode: rowVersion
|
||||
});
|
||||
return {
|
||||
id: normalizedOptions.id,
|
||||
builderKey: "s5074",
|
||||
code: "5074",
|
||||
aliases: ["5074"],
|
||||
title: normalizedItems.map(item => item.name).join(", "),
|
||||
detail: "5단 표그래프 · VI 발동(수동)",
|
||||
category: "plate",
|
||||
market: "index",
|
||||
itemCount: normalizedItems.length,
|
||||
pageSize: VI_PAGE_SIZE,
|
||||
pageCount,
|
||||
selection,
|
||||
enabled: true,
|
||||
fadeDuration: normalizedOptions.fadeDuration,
|
||||
graphicType: selection.graphicType,
|
||||
subtype: selection.subtype,
|
||||
operator: Object.freeze({
|
||||
source: "legacy-manual-lists-workflow",
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
kind: "vi",
|
||||
items: normalizedItems,
|
||||
rowVersion,
|
||||
pagePreview: Object.freeze({
|
||||
itemCount: normalizedItems.length,
|
||||
pageSize: VI_PAGE_SIZE,
|
||||
pageCount,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT
|
||||
})
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function sameSelection(left, right) {
|
||||
const keys = ["groupCode", "subject", "graphicType", "subtype", "dataCode"];
|
||||
return hasExactKeys(left, keys) && hasExactKeys(right, keys) && keys
|
||||
.every(key => typeof left[key] === "string" && left[key] === right[key]);
|
||||
}
|
||||
|
||||
function restorePlaylistEntry(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || typeof value.enabled !== "boolean") return null;
|
||||
const operator = value.operator;
|
||||
if (!operator || operator.source !== "legacy-manual-lists-workflow" ||
|
||||
operator.schemaVersion !== SCHEMA_VERSION || !["net-sell", "vi"].includes(operator.kind)) return null;
|
||||
const topKeys = operator.kind === "net-sell"
|
||||
? [
|
||||
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
||||
"selection", "enabled", "fadeDuration", "graphicType", "subtype", "operator"
|
||||
]
|
||||
: [
|
||||
"id", "builderKey", "code", "aliases", "title", "detail", "category", "market",
|
||||
"itemCount", "pageSize", "pageCount", "selection", "enabled", "fadeDuration",
|
||||
"graphicType", "subtype", "operator"
|
||||
];
|
||||
const operatorKeys = operator.kind === "net-sell"
|
||||
? ["source", "schemaVersion", "kind", "audience"]
|
||||
: ["source", "schemaVersion", "kind", "items", "rowVersion", "pagePreview"];
|
||||
if (!hasExactKeys(value, topKeys) || !hasExactKeys(operator, operatorKeys)) return null;
|
||||
if (operator.kind === "vi" &&
|
||||
(!isRowVersion(operator.rowVersion) ||
|
||||
!hasExactKeys(operator.pagePreview, ["itemCount", "pageSize", "pageCount", "maximumPageCount"]))) {
|
||||
return null;
|
||||
}
|
||||
let recreated;
|
||||
try {
|
||||
const options = { id: value.id, fadeDuration: value.fadeDuration };
|
||||
recreated = operator.kind === "net-sell"
|
||||
? createNetSellPlaylistEntry(operator.audience, options)
|
||||
: createViPlaylistEntry(operator.items, operator.rowVersion, options);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const scalars = [
|
||||
"id", "builderKey", "code", "title", "detail", "category", "market",
|
||||
"fadeDuration", "graphicType", "subtype"
|
||||
];
|
||||
if (!scalars.every(key => value[key] === recreated[key]) ||
|
||||
!Array.isArray(value.aliases) || value.aliases.length !== 1 || value.aliases[0] !== recreated.aliases[0] ||
|
||||
!sameSelection(value.selection, recreated.selection)) return null;
|
||||
if (operator.kind === "vi" &&
|
||||
(value.itemCount !== recreated.itemCount || value.pageSize !== recreated.pageSize ||
|
||||
value.pageCount !== recreated.pageCount ||
|
||||
operator.pagePreview.itemCount !== recreated.operator.pagePreview.itemCount ||
|
||||
operator.pagePreview.pageSize !== recreated.operator.pagePreview.pageSize ||
|
||||
operator.pagePreview.pageCount !== recreated.operator.pagePreview.pageCount ||
|
||||
operator.pagePreview.maximumPageCount !== recreated.operator.pagePreview.maximumPageCount)) return null;
|
||||
return { ...recreated, enabled: value.enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
bridgeContract,
|
||||
audiences,
|
||||
audienceLabels,
|
||||
maximumViItems: MAXIMUM_VI_ITEMS,
|
||||
viPageSize: VI_PAGE_SIZE,
|
||||
maximumPageCount: MAXIMUM_PAGE_COUNT,
|
||||
viSnapshotSubject: VI_SNAPSHOT_SUBJECT,
|
||||
normalizeAudience,
|
||||
normalizeNetSellRow,
|
||||
normalizeNetSellRows,
|
||||
normalizeViItem,
|
||||
normalizeViItems,
|
||||
createStatusRequest,
|
||||
createNetSellReadRequest,
|
||||
createNetSellSaveRequest,
|
||||
createViReadRequest,
|
||||
createViSaveRequest,
|
||||
normalizeStatusResponse,
|
||||
normalizeNetSellDataResponse,
|
||||
normalizeNetSellSaveResponse,
|
||||
normalizeViDataResponse,
|
||||
normalizeViSaveResponse,
|
||||
normalizeError,
|
||||
createNetSellPlaylistEntry,
|
||||
createViPlaylistEntry,
|
||||
restorePlaylistEntry,
|
||||
refreshPlaylistEntry: restorePlaylistEntry
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user