diff --git a/MBN_STOCK_WEBVIEW.sln b/MBN_STOCK_WEBVIEW.sln index 29ec317..6c72f10 100644 --- a/MBN_STOCK_WEBVIEW.sln +++ b/MBN_STOCK_WEBVIEW.sln @@ -38,6 +38,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyBri EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.LegacyWeb.Tests", "tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests\MBN_STOCK_WEBVIEW.LegacyWeb.Tests.csproj", "{9333727F-7367-4AF8-BA9D-EF159A3148D9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.DbWriteSmoke", "tools\MBN_STOCK_WEBVIEW.DbWriteSmoke\MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj", "{76A4348A-237C-4634-9861-43102CBA24D6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests", "tests\MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests\MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests.csproj", "{263E151C-50C1-4B6B-A219-4013273D19AF}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|x64 = Debug|x64 @@ -168,6 +172,22 @@ Global {9333727F-7367-4AF8-BA9D-EF159A3148D9}.PrototypeDebug|x64.Build.0 = Debug|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.ActiveCfg = Release|Any CPU {9333727F-7367-4AF8-BA9D-EF159A3148D9}.Release|x64.Build.0 = Release|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.Debug|x64.ActiveCfg = Debug|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.Debug|x64.Build.0 = Debug|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.ParityDebug|x64.Build.0 = Debug|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.Release|x64.ActiveCfg = Release|Any CPU + {76A4348A-237C-4634-9861-43102CBA24D6}.Release|x64.Build.0 = Release|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.Debug|x64.ActiveCfg = Debug|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.Debug|x64.Build.0 = Debug|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.ParityDebug|x64.ActiveCfg = Debug|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.ParityDebug|x64.Build.0 = Debug|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.PrototypeDebug|x64.ActiveCfg = Debug|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.PrototypeDebug|x64.Build.0 = Debug|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.Release|x64.ActiveCfg = Release|Any CPU + {263E151C-50C1-4B6B-A219-4013273D19AF}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -187,6 +207,8 @@ Global {60A364DD-1283-4DA1-8660-791B5CB4F86F} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {8F28CB75-54A4-4C27-A7F8-88A30D485B19} = {0AB3BF05-4346-4AA6-1389-037BE0695223} {9333727F-7367-4AF8-BA9D-EF159A3148D9} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {76A4348A-237C-4634-9861-43102CBA24D6} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {263E151C-50C1-4B6B-A219-4013273D19AF} = {0AB3BF05-4346-4AA6-1389-037BE0695223} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {58E98A35-4C8C-4C80-9227-1063B345AD07} diff --git a/docs/LEGACY_FEATURE_AUDIT.md b/docs/LEGACY_FEATURE_AUDIT.md index 75b3a76..58ad9f4 100644 --- a/docs/LEGACY_FEATURE_AUDIT.md +++ b/docs/LEGACY_FEATURE_AUDIT.md @@ -1,148 +1,149 @@ # 원본 기능 재감사 기준표 -감사 기준일: 2026-07-12 +감사 기준일: 2026-07-15 + 원본(읽기 전용): `C:\Users\MD\source\repos\MBN_STOCK_N` + 마이그레이션 대상: `C:\Users\MD\source\repos\MBN_STOCK_WEBVIEW` -이 문서는 원본 WinForms/FarPoint 프로그램과 현재 WinUI 3/WebView2 프로그램을 다시 대조한 결과다. 원본 저장소와 실제 Cuts는 읽기 전용으로 조사했다. 자동 테스트나 UI 코드가 존재한다는 사실과 실제 운영 환경에서 동등성이 확인됐다는 사실을 구분한다. +## 감사 결론 + +현재 실행 대상은 `.NET 8 / C# / WinUI 3 / Windows App SDK 1.8 / WebView2` 기반 +`MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 이는 웹사이트가 아니라 로컬 Web 자산을 +WebView2에 표시하는 x64 Windows 데스크톱/MSIX 앱이다. + +초기 프로토타입 문서의 “미이관 버튼 disabled”, “Tornado2에 연결하지 않음”, “DB write +없음”은 현재 상태가 아니다. 원본 UI와 클릭 이벤트는 LegacyParityApp → strict bridge → +C# workflow로 연결됐고, 개발 DB write 9개 시나리오와 설치된 MSIX의 개발 PGM 최종 +통합까지 검증했다. 기본 송출 모드는 계속 `DryRun`이다. ## 상태 정의 | 상태 | 의미 | |---|---| -| `미적용` | 원본 동작의 대응 구현 또는 필수 검증 경로가 아직 없다. | -| `적용 중` | 현재 작업 트리에 구현 또는 검사 보강이 있으나 최종 패키지·실환경 검증까지 끝나지 않았다. | -| `완료` | 현재 코드와 해당 자동 검증에 반영됐고 이번 재감사에서 완료로 판정했다. | -| `외부자산 필요` | 코드만으로 완료할 수 없으며 Git/MSIX 밖의 승인된 자산이나 운영 환경이 필요하다. | - -완료 수는 증분 포팅과 최종 회귀 결과에 따라 바뀌므로 아래 표를 단일 기준으로 사용한다. 기존 문서에서 사용하는 `완료`, `통합 완료`, `전체 통과` 표현도 아래 상태와 검증 범위로 다시 해석해야 한다. +| `완료` | 현재 LegacyParityApp 코드와 해당 자동/통합 검증에 반영됨 | +| `외부자산 필요` | 코드로 만들 수 없는 Git/MSIX 밖의 승인 영상·배경이 제공되지 않음 | +| `외부 데이터 정합성` | 현재 DB view/data가 원본 규칙의 fresh identity를 공급하지 않아 추정 없이 차단함 | ## 우선순위별 결과 -| ID | 우선순위 | 기능·누락 후보 | 상태 | 근거 파일·메서드 | 현재 판정 | 검증 방법 | -|---|---|---|---|---|---|---| -| LF-001 | P0 | `s6001` 해외지수 영상 자산 | 외부자산 필요 | 현재 `src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs:491-513`, `S6001SceneMutationBuilder.Metadata`/`Index`; 검증기 `src/MBN_STOCK_WEBVIEW.Playout/Configuration/ValidatedPlayoutOptions.cs:375-406`, `ResolveAssetPath` | 해외지수 15개 target이 13개 `Video\20201008_<국가>.vrv`에 의존한다. 감사 시 승인 Cuts의 `Video` 폴더는 비어 있어 이 target들은 DryRun PREPARE에서도 COM 호출 전에 차단된다. 유가·금의 PNG/JPG는 존재한다. | 승인된 파일을 Cuts root 아래에 제공한 뒤 강화된 cut/asset 검사와 s6001 각 target DryRun PREPARE를 실행한다. 실제 PGM은 별도 승인 회차에서만 확인한다. | -| LF-002 | P0 | cut coverage의 장면 종속 asset 검사 | 완료 | `scripts/LegacyCutRequirements.json`, `LegacyCutCoverage.psm1`, `Test-LegacyCutCoverage.ps1`; 테스트 `tests/Scripts/Test-LegacyCutCoverage.Tests.ps1`, `LegacyCutRequirementManifestTests.cs` | 45개 active alias와 23개 장면 종속 asset을 분리해 존재·0바이트·root escape·reparse·manifest 중복을 검사한다. 완전/누락/0바이트/reparse/escape fixture가 자동 테스트에 연결됐다. 실제 승인 Cuts read-only 실행은 alias missing/unsafe 0, asset unsafe 0, asset missing 14로 정확히 실패했다: `s5006` 1개와 `s6001` 13개 영상이다. | 실제 누락 14개가 승인 Cuts에 제공되면 같은 명령을 다시 실행해 `Status=Passed`를 확인한다. 검사기 완료와 외부 자산 준비 완료는 LF-001/LF-003에서 분리한다. | -| LF-003 | P0 | `s5006` 큐브 배경 영상 | 외부자산 필요 | 원본 `MBN_STOCK_N/Scene/s5006.cs:30-31`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/ReadOnlyPanelSceneBuilders.cs:31-48`; `docs/PLAYOUT_OPERATIONS.md:81-85` | `Video\큐브배경.vrv`가 승인 Cuts에 없다. 기존 운영 문서에는 누락이 기록돼 있으나 파일 제공 전에는 `s5006` PREPARE가 완료된 것이 아니다. | asset 검사 통과 후 s5006 DryRun PREPARE, 승인된 Test/PGM 회차 순으로 검증한다. | -| LF-004 | P0 | 실제 운영 DB write | 미적용 | `docs/OPERATOR_UI_PARITY.md:48-62,167-170,214-218,229-246,278`; GraphE `MainWindow.ManualFinancial.cs`; PList `MainWindow.NamedPlaylists.cs`; ThemeA/EList `MainWindow.OperatorCatalogs.cs` | mutation 계약, fake executor, rollback·OutcomeUnknown 테스트는 존재하지만 GraphE, PList/AList, ThemeA, EList의 운영 Oracle write는 실행하지 않았다. FSell/VI도 격리 저장소 write만 검증했다. 따라서 운영 데이터 쓰기 동등성은 미검증이다. | 승인된 테스트용 식별자와 사전 snapshot을 정하고 create/update/delete를 각 1회 실행한다. 매 단계 후 별도 read-only 조회로 결과를 대조하고, timeout·OutcomeUnknown이면 반복하지 않는다. 운영 데이터에는 임의로 실행하지 않는다. | -| LF-005 | P0 | 전체 장면·운영 UI의 실제 Tornado2/PGM 검증 | 미적용 | `docs/MIGRATION.md:69-74`; `docs/OPERATOR_UI_PARITY.md:48-62`; `docs/PLAYOUT.md:348-366` | 승인된 실제 회차의 범위는 5001/5074와 그 Page/playlist NEXT, refresh, TAKE OUT이다. 자동 builder/DB smoke 결과를 나머지 장면의 PGM 동등성으로 확대할 수 없다. | 장면별 허용 자산과 DB 전제조건을 먼저 통과시킨다. 이후 회차별 계획 SHA-256과 명시적 승인을 받아 Network Monitoring 명령, callback, PGM 화면을 함께 기록한다. | -| LF-006 | P1 | 시스템 종료 확인 | 완료 | `MainWindow.CloseConfirmation.cs`, `EnsureCloseConfirmationAttached`, `OnAppWindowClosing`, `BuildCloseConfirmationMessage`, `DetachCloseConfirmation`; 연결부 `MainWindow.xaml.cs:54-63,101-112,OnClosed`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:384-408` | 모든 시스템 close를 먼저 취소하고 기본 버튼이 취소인 확인창을 연다. PREPARED/PROGRAM, command pending, OutcomeUnknown에 맞는 경고를 표시하며 종료 과정에서 TAKE OUT이나 반대 명령을 자동 실행하지 않는다. | Web 통합 테스트를 실행하고 패키지 앱에서 idle, prepared/on-air 경고, 취소, 명시적 종료를 수동 확인한다. | -| LF-007 | P1 | 패키지 앱 단일 인스턴스 | 완료 | `App.xaml.cs:9-57`, `OnLaunched`, `OnMainInstanceActivated`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:72-88` | `AppInstance.FindOrRegisterForKey`가 MainWindow와 DB/WebView/playout runtime 생성 전에 실행된다. 두 번째 인스턴스는 activation을 primary로 전달한 뒤 종료하며 기존 프로세스를 이름으로 찾아 강제 종료하지 않는다. | 통합 테스트 후 설치된 MSIX를 두 번 실행해 프로세스 하나, 창 하나, 기존 창 활성화를 확인한다. | -| LF-008 | P1 | 운영자 모달의 전역 단축키 격리 | 완료 | `Web/app.js:6808-6845`, `hasOpenOperatorModal`, `isolateOperatorModalShortcut`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs:410-429` | GraphE, FSell/VI, ThemeA/EList 등 modal이 열려 있으면 F2/F3/F8/Escape, Home/End/Delete/Space, Ctrl+K가 workspace 송출·playlist handler로 전달되지 않는다. F8/Escape에는 modal을 먼저 닫으라는 안내를 표시한다. | Web 통합 테스트 후 각 modal 입력 필드에서 해당 키를 눌러 playout command와 playlist mutation이 발생하지 않는지 확인한다. | -| LF-009 | P1 | PList의 기존 한국어 `DC_TITLE`/`LIST_TEXT` 복원 | 적용 중 | 원본 `MBN_STOCK_N/Form/PList.cs:81-124`, `data_load`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyPListReadAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedPlaylistReadOnlyDbAudit.cs`, `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`; KRX/해외종목/manual 전용 restore와 테스트; 상세 `docs/NAMED_PLAYLIST_READ_AUDIT.md` | 실제 Oracle 활성 정의 23개와 2,213행 모두 정확히 7필드이고 원문 재결합, 한국어 제목·필드, enabled, page 공백 제거 및 typed persistence가 일치했다. 기본 감사는 KRX 94행 current-code remap과 해외종목 78/78을 포함해 `selectionMapped=1,581`, 차단 632다. 별도 GraphE fresh 감사가 manual 36행 중 22행을 materialize하고 저장값 무효 9행·identity 중복 5행을 차단해 두 증거의 통합 결과는 `selectionMapped=1,603`, 차단 610이다. 이 수치는 scene data·asset·PREPARE·PGM 성공이 아니다. | 집중 테스트, Release 실제 read-only DB 감사와 전체 `REAL_DB_SMOKE: PASS`. FSell/VI PList 행은 0건이지만 운영 production read는 FSell 3×5행, VI 9항목·2 page이고 파일 전후 불변이다. raw 값·인증정보 출력, DB write, Tornado/PGM 명령은 0건이다. 남은 KRX/NXT/live-data·GraphE identity/data·unique mapper 차단을 해소하고 scene/asset/package/PGM gate를 별도로 통과하기 전에는 전체 복원 완료로 올리지 않는다. | -| LF-010 | P1 | 원본 F2/F3 배경 디렉터리 동등성 | 코드 적용 / 외부자산 필요 | 원본 `MBN_STOCK_N/MainForm.cs:4008-4054`, `btnback_Click`, `ckback_CheckedChanged`; 현재 `MainWindow.Background.cs`, `PlayoutSceneCompositionFactory`, `TrustedPlayoutAssetPath`, `ValidatedPlayoutOptions`; 문서 `docs/TRUSTED_BACKGROUND_ROOT.md` | Cuts gate를 유지한 채 별도 `LegacyBackgroundDirectory`를 추가했고, 생략 시 원본처럼 `SceneDirectory\..\배경`을 계산한다. F2는 유효할 때만 picker 전 `기본.vrv`를 복원하고 F3-on은 항상 기본 파일을 다시 검증한다. root escape·조상/파일 reparse·hardlink·누락·opened-handle final path를 fail closed로 검사하며 Cuts fallback은 없다. 이 장비의 원본 `bin\Debug\배경`과 `기본.vrv`는 실제로 없어 현재 기본 배경은 잠긴다. | Core Debug/Release 1,377/1,377, Playout 387/387과 VS2026 앱 Debug/Release x64 build를 통과했다. signed 1.0.4 package는 누락 상태를 별도 배경 상태로 표시한다. 자산 제공 뒤 F2/F3와 다음 PREPARE mutation/PGM은 승인 회차에서 검증한다. | -| LF-011 | P1 | `종목비교.dat` 기존 저장쌍 이전 | 완료 | 원본 `MBN_STOCK_N/Control/UC3.cs:34-55,181-185`, `UC3_Load`, `Data_Load`, `File_Save`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyComparisonPairImport.cs`, `MainWindow.LegacyComparisonImport.cs`, `Web/comparison-import-workflow.js`, `Web/app.js`; 테스트 `LegacyComparisonPairImportServiceTests.cs`, `comparison-import-workflow.test.cjs`, `comparison-import-app-integration.test.cjs`; 실행 증거 `docs/LEGACY_ONE_TIME_IMPORT_AUDIT.md` | 원본 CP949 358바이트·8행·9필드와 SHA-256 `1EE76BC4…BFC0D2F2`를 고정하고, 고정 read-only source·strict parser·현재 Oracle/MariaDB exact identity·전체 원자 저장·one-time marker를 적용했다. 설치된 signed 1.0.3 package에서 빈 상태 `0 PAIRS`를 확인한 뒤 명시적 버튼을 정확히 한 번 실행해 8쌍을 저장했다. 정상 종료·재시작 후에도 `8 PAIRS`, 원본 순서의 8행과 완료형 `원본 8쌍 가져옴` gate가 유지됐고, 원본 파일 SHA-256은 불변이었다. 원본·DB·Tornado에는 쓰기나 명령을 보내지 않았다. | Core 20/20, Web 12/12, 실제 read-only DB identity 8/8, package 0→8 및 재시작 marker 영속성 PASS. 손상·동명이름·기존쌍 중복·부분 실패는 계속 전체 fail closed이며 package family data를 초기화해 같은 회차를 재현하지 않는다. | -| LF-012 | P1 | FSell/VI 원본 `Data`의 안전한 이전과 설정 문서 | 적용 중 | 원본 `MBN_STOCK_N/MainForm.cs:338-339`, `FSell.cs:31-88`, `VIList.cs:31-34,137-139`; importer `LegacyManualOperatorDataImport`, `LegacyManualOperatorDataImporter`; production read `S5025TrustedManualFileDataSource`, `ViTrustedManualFileStore`; 감사 `tools/MBN_STOCK_WEBVIEW.DbSmoke/NamedManualRestoreReadOnlyAudit.cs`, `docs/NAMED_MANUAL_RESTORE_READ_ONLY_AUDIT.md` | 고정 원본 경로·strict CP949·source/hash 재검증·no-overwrite staged commit·marker-last·OutcomeUnknown quarantine를 유지한다. 격리 fixture는 FSell 15행·VI 9건, aggregate SHA-256 `fd39a363…a2114`, 첫 실행 성공과 두 번째 `AlreadyImported`를 확인했다. 운영 profile은 이미 원본과 byte-identical한 4파일이 있어 importer가 `DestinationNotEmpty`로 차단되며 marker/intent를 만들지 않는다. production opened-handle read는 FSell 3 audience 각각 5행, VI 9항목·2 page와 안정된 두 번째 version을 확인했고 감사 전후 4파일은 불변이다. 현재 PList에는 FSell/VI 행이 0건이다. | 새 빈 signed package profile의 명시적 UI importer 검증은 남아 있다. 현재 운영 profile의 동일 파일을 덮어쓰거나 marker를 임의 생성하지 않는다. PList에 향후 FSell/VI 행이 생기면 같은 fresh source/version gate를 거치며 scene data·asset·PREPARE·PGM은 별도 검증한다. | -| LF-013 | P1 | generic native `bridge-error` 표시 | 완료 | producer `MainWindow.xaml.cs`, `MainWindow.Playout.cs`; consumer `Web/app.js` `handleBridgeError`/`handleNativeMessage`; 테스트 `operator-ui-app-integration.test.cjs` | bounded toast/log로 소비하며 malformed status/quarantine payload에서 playout state를 변경하지 않는다. 전체 Web 413/413과 signed 1.0.4 패키지 반영을 확인했다. | 새 native error producer를 추가할 때마다 같은 bounded consumer 계약을 유지한다. | -| LF-014 | P2 | 영구 운영 로그 | 완료 | 원본 `MBN_STOCK_N/MainForm.cs:84-180`; 현재 `NativeOperatorLogWriter.cs`, `NativeOperatorLogTransitions.cs`, `MainWindow.NativeOperatorLog.cs` | `%LOCALAPPDATA%\MBN_STOCK_WEBVIEW\Logs\Log_MMdd.log`에 폐쇄 event/state만 기록하며 4MiB/31일, 중복 억제, reparse/handle 검증, single terminal/OutcomeUnknown 규칙을 적용한다. signed 1.0.4 package context에서도 Startup→DryRunReady→Oracle/MariaDB Healthy가 기록됐고 playout command·PROGRAM·OutcomeUnknown은 0건이었다. SQL·경로·인증·requestId를 기록하지 않는다. | 운영 회차에서도 로그를 명령 재시도 근거로 사용하지 않고 authoritative 출력과 함께 판독한다. | -| LF-015 | P2 | 호출자가 없는 native bridge handler | 완료 | `MainWindow.xaml.cs`, `OnWebMessageReceived`; 테스트 `tests/Web/operator-ui-app-integration.test.cjs` | Web 호출자가 없던 `open-external`과 `reload` message case를 native allowlist에서 제거했다. 외부 navigation/new-window 처리와 WebView process failure의 내부 reload 복구 메서드는 별도 native event 경로로 유지한다. | 정적 계약 테스트에서 두 message case와 Web 발신 literal이 모두 없음을 확인했다. | -| LF-016 | P2 | 실제 preview 이미지·영상 | 외부자산 필요 | `docs/MIGRATION.md:73-74`; 원본 `MBN_STOCK_N/MainForm.cs:1448-1462,1894-1911,2919-2921` | 현재 preview는 안전한 DTO/mutation 텍스트 중심이다. 실제 이미지·영상 연결은 후속 자산이다. 다만 원본 thumbnail 호출과 watcher는 주석 또는 미호출이므로 활성 원본 동등성의 P0/P1 blocker로 보지 않는다. | 승인된 preview 자산이 생기면 asset root와 노출 정보 검사를 거쳐 패키지 화면에서 확인한다. | -| LF-017 | P1 | 모든 업무 탭에서 계속 보이는 종목 검색·31컷 | 완료 | 원본 `MBN_STOCK_N/MainForm.Designer.cs:2022-2036,2454-2462`; 현재 `Web/index.html`의 독립 `stock-panel`, `Web/app.js`의 `renderStockWorkflow`; 테스트 `operator-ui-app-integration.test.cjs`; package 증거 `docs/RELEASE_1_0_4_AUDIT.md` | 종목 검색·31컷을 catalog panel 밖의 독립 열로 옮겼다. signed 1.0.4 package에서 실제 `삼성` 검색 63건을 확인하고 10개 업무 탭을 모두 방문한 뒤에도 검색어·결과·31개 컷이 유지됐다. package는 계속 DryRun이었다. | Web 413/413, source Web 33개 package byte 일치, 설치 package UI PASS. 향후 새 업무 탭도 같은 독립 패널 계약을 유지한다. | -| LF-018 | P2 | 원본 트리·컷 목록 일괄 추가 제스처 | 적용 중 | 원본 부모 double-click `MBN_STOCK_N/Control/UC1.cs:141-180`; 소스 컷 Ctrl/Shift 선택 `MBN_STOCK_N/MainForm.cs:3427-3525,3534-3549,3635-3643`; 현재 `Web/legacy-batch-selection-workflow.js`, `Web/app.js`; 테스트 `legacy-batch-selection-*.test.cjs` | 종목 31컷의 plain/Ctrl/Shift/Ctrl+Shift 선택, 단일 double-click, `선택 컷 추가`를 포팅했다. 해외·환율·지수 fixed source의 섹션 `전체 추가`는 모든 자식을 먼저 materialize한 뒤 원본 순서로 한 번에 commit하며, 수동/지원불가가 한 건이라도 있으면 부분 추가 없이 전체 차단한다. 실제 브라우저에서 5개 선택, 해외 43개 순서 추가, 수동 혼합 섹션 0건 추가를 확인했다. | signed MSIX 확인 뒤 업종·비교·테마 등 전용 source group의 부모 전체 추가가 원본에서 실제로 필요한 범위를 계속 분리 포팅한다. source drag/drop 자체는 오조작 검증 뒤 결정한다. | -| LF-019 | P1 | 저장 PList의 `테마_NXT` stale code 복원 | 코드 완료 / DB view 정합성 대기 | 원본 `Control/UC4.cs`, `Scene/s5074.cs`, `s5077.cs`, `s5088.cs`; 현재 `src/MBN_STOCK_WEBVIEW.Core/Data/LegacyNamedNxtThemeRestore.cs`, `MainWindow.NamedNxtThemeRestore.cs`, `Web/named-nxt-theme-restore-workflow.js`; 테스트 `LegacyNamedNxtThemeRestoreServiceTests.cs`, `named-nxt-theme-restore-workflow.test.cjs`, `named-nxt-theme-restore-app-integration.test.cjs`; 상세 `docs/NAMED_NXT_THEME_RESTORE.md`, `docs/NXT_THEME_RESTORE_READ_ONLY_AUDIT.md` | 제목 exact lookup, 현재 Maria code, 5/6/12행, 정렬 fallback, 00~12 PRE/13~23 AFTER와 fresh PageN을 닫힌 batch로 복원한다. 실제 48행/24제목은 current identity가 유일하고 저장 code는 전부 stale다. `SB_ITEM` 446행과 NXT master 446행은 존재하지만 원본과 새 앱이 공통으로 쓰는 `v_all_stock` 조인은 0행이어서 48행 모두 `PreviewEmpty`로 차단된다. 통합 PList 결과에서도 `selectionMapped`로 계산하지 않았고 추측성 직접-table fallback을 적용하지 않았다. | Core Debug/Release와 실제 read-only DB smoke PASS, DB write·Tornado/PGM 명령 0건. DBA가 `v_all_stock`의 NXT 포함 의도를 확인해 승인된 변경·rollback으로 복구하거나 직접-table 경로를 별도 승인한 뒤 fresh page preflight/MSIX/PGM을 검증한다. | -| LF-020 | P2 | 원본 초기 업무 화면 `해외` | 완료 | 원본 `MainForm.Designer.cs` 첫 TabPage `해외`; 현재 `Web/index.html`, `Web/app.js`; 테스트 `operator-ui-app-integration.test.cjs` | dashboard는 진단 화면으로 유지하되 초기 active identity/제목/요청을 해외로 맞췄다. signed 1.0.4 package를 새로 실행했을 때 해외만 active였고 실제 해외 DB 상태가 표시됐다. | 후속 탭 추가 시에도 초기 identity를 순번이 아닌 `data-market`으로 유지한다. | -| LF-021 | P2 | 업무 탭 드래그 순서 변경 | 완료 | 원본 `MainForm.cs:3183-3312`; 현재 `Web/market-nav-reorder.js`, `Web/app.js`; 테스트 `market-nav-reorder*.test.cjs` | dashboard는 첫 위치에 고정하고 10개 업무 identity만 drag 교환/Alt+위·아래로 이동한다. active view/DB/playlist는 불변이고 입력·modal·PREPARE 잠금에서 fail closed하며 세션 종료 시 기본 순서로 돌아간다. 실제 브라우저에서 `Alt+Down` 후 active 해외 유지와 reload 초기화를 확인했다. | signed 1.0.4 패키지에 source Web 33개 일치로 반영했다. | -| LF-022 | P2 | GraphE 위/아래 찾기 | 완료 | 원본 `GraphE.cs:338-379,605-611`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `위로 찾기`/`아래로 찾기` 버튼과 Enter 다음 찾기를 포팅했다. trim·대소문자 무시 Contains, 현재 행 전/후, no-wrap, 모든 pending·잠금·hidden 차단을 유지한다. 원본의 `i > 0` 첫 행 누락은 명백한 off-by-one으로 보고 첫 행도 정상 후보에 포함했다. | GraphE 집중 20/20, 전체 Web 413/413과 signed 1.0.4 Web 일치 통과. | -| LF-023 | P2 | ThemeA/전문가 편집기 Delete 키 | 완료 | 원본 `ThemeA.cs`/`UC6.cs`; 현재 `Web/operator-catalog-ui.js`; 테스트 `operator-catalog-ui.test.cjs` | ThemeA와 expert draft의 포커스/선택 행 하나를 기존 `removeDraftItem`으로 삭제하고 input/textarea/contenteditable, modal 밖, 잠금/DB write 중에는 상태를 변경하지 않는다. 삭제 후 inputIndex를 재배열하며 native 요청은 보내지 않는다. | 관련 UI/workflow/bridge 32/32, 전체 Web 413/413과 signed 1.0.4 Web 일치 통과. | -| LF-024 | P2 | GraphE 종목명 헤더 정렬 | 완료 | 원본 `GraphE.cs:258-280`; 현재 `Web/manual-financial-ui.js/.css`; 테스트 `manual-financial-ui.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 세션 로컬 display order로 적용했다. 선택 snapshot/native/playlist는 불변이고 위/아래 찾기는 현재 표시 순서를 따른다. 새 화면/목록에서 오름차순으로 초기화한다. | 네 모드·한글/ASCII·빈/단일·pending·정렬된 find 집중 20/20, 전체 Web 413/413 통과. | -| LF-025 | P2 | UC7 거래정지 종목명 헤더 정렬 | 완료 | 원본 `Control/UC7.cs:173-188`; 현재 `Web/trading-halt-workflow.js`, `Web/app.js`, `Web/index.html`; 테스트 `trading-halt-name-sort-app-integration.test.cjs` | `종목명 ↕` 첫 click 내림차순/둘째 click 오름차순을 표시 order에만 적용했다. 동명은 market+stockCode로 결정적으로 정렬하고 선택 identity를 유지하며 새 검색은 server 순서로 초기화한다. DB/native/playlist 변경은 없다. | 집중 18/18, 전체 Web 413/413, 실제 브라우저에서 접근 라벨/가로 overflow 0, signed 1.0.4 Web 일치 통과. | +| ID | 우선순위 | 기능 | 상태 | 현재 판정과 증적 | 남은 경계 | +|---|---|---|---|---|---| +| LF-001 | P0 | `s6001` 해외지수 영상 | 외부자산 필요 | builder와 trusted asset 검사는 완료. 승인 Cuts에 국가 영상 13개가 없음 | 제공 후 asset preflight와 해당 scene PGM | +| LF-002 | P0 | cut/asset coverage | 완료 | active alias 45개 `.t2s` 존재. alias missing/unsafe 0, asset unsafe 0, asset missing 14를 분리 보고 | 누락 자산 자체는 LF-001/LF-003 | +| LF-003 | P0 | `s5006` 큐브 배경 | 외부자산 필요 | `Video\큐브배경.vrv` 1개가 승인 Cuts에 없음. builder 누락이 아님 | 제공 후 s5006 DryRun/PGM | +| LF-004 | P0 | 실제 개발 DB write | 완료 | GraphE 4 + PList/AList 2 + ThemeA KRX/NXT 2 + EList 1, 총 9개가 create/save→fresh readback→delete→absence PASS, 잔여 0 | 운영 DB write를 뜻하지 않음 | +| LF-005 | P0 | 개발 Tornado2/PGM 최종 통합 | 완료 | 설치된 최신 MSIX에서 `5001` PREPARE/TAKE IN/자동 갱신 → playlist NEXT `5074` 1/2 → Page NEXT 2/2 → TAKE OUT, Network Monitoring·PGM·BYE 확인 | 14개 외부 영상 자산 scene은 제공 후 별도 PGM | +| LF-006 | P0 | 원본 35 scene | 완료 | 원본 inventory와 source 기준선 35/35, DTO→mutation→runtime matrix 통과 | 모든 scene의 실제 PGM 완료로 확대 해석 금지 | +| LF-007 | P0 | PageN/NEXT | 완료 | 5·6·12개 단위, 최대 20페이지, 빈/경계/마지막 부분 페이지 테스트와 개발 PGM에서 `5074` 9종목의 5+4 Page NEXT·다음 행 NEXT 확인 | 없음 | +| LF-008 | P0 | 원클릭 TAKE IN | 완료 | 보이는 TAKE IN/F8 한 번으로 fresh page load 후 PREPARE 1회→TAKE IN 1회. native PREPARE는 진단용으로 유지하되 hidden | unknown에서 재시도 금지 | +| LF-009 | P0 | C# 권위/strict bridge | 완료 | Web은 intent만 전송. `LegacyBridgeProtocol` allowlist와 `LegacyOperatorController` snapshot이 DB identity·playlist·scene·송출 상태의 권위 | 새 intent에도 동일 경계 유지 | +| LF-010 | P1 | 10개 탭과 action inventory | 완료 | 10탭, fixed 328, 업종 44, 종목 31, 비교 target 24/action 9를 폐쇄형으로 고정 | 동적 DB 행은 별도 검증 | +| LF-011 | P1 | UC4/UC6/UC7 클릭 | 완료 | 테마·전문가·거래정지는 원본 단일 클릭. UC4/UC6 행은 fixed tree handler와 독립, stale generation 거부 | 없음 | +| LF-012 | P1 | GraphE/FSell/VI | 완료 | GraphE CRUD·찾기·정렬·행 double-click, FSell/VI 확인 시 save+playlist append+close, 취소 경계 이관 | s5025/VI 전용 PGM은 LF-005 | +| LF-013 | P1 | PList/AList | 완료 | 목록·생성·save/load/delete·행 double-click 이관, 개발 DB write/readback/cleanup PASS | 기존 blocked raw row는 추정 복원 금지 | +| LF-014 | P1 | ThemeA/EList | 완료 | 검색·신규·편집·삭제·종목 구성·순서·Delete key 이관, KRX/NXT/expert 개발 DB 시나리오 PASS | scene PGM은 LF-005 | +| LF-015 | P1 | UC5 해외 | 완료 | 해외업종·해외종목·해외지수 조회와 playlist 경로 이관 | s6001 13개 영상은 LF-001 | +| LF-016 | P1 | F2/F3 기본 배경 | 외부자산 필요 | sibling background root, picker, background-none와 trusted path 검사는 완료. 이 장비에 `기본.vrv`가 없음 | 제공 전 fail closed | +| LF-017 | P1 | 시스템 종료 확인 | 완료 | package-context에서 기본 취소 확인창의 중립 문구·종료/취소 두 버튼을 대조하고 종료 1회, BYE 1회, 자동 TAKE OUT/반대 명령 없음 확인 | 없음 | +| LF-018 | P1 | 원본 비교쌍/수동 파일 | 완료 | `종목비교.dat` 8쌍 1회 import·재시작 marker, FSell 3×5행과 VI 9항목 trusted parser/store 검증 | 원본 파일은 계속 읽기 전용 | +| LF-019 | P1 | 실제 DB read→DTO→mutation | 완료 | Oracle/MariaDB 필수 scene selector 33/33, 전체 query 58건 PASS | 외부 asset/PGM 성공과 별도 | +| LF-020 | P2 | FarPoint 대체 | 완료 | Web table로 바꾸되 선택·정렬·이동·single/double-click·저장 이벤트를 strict C# intent로 재현 | 활성 Print/Excel export는 원본에도 없음 | +| LF-021 | P2 | 패키지 단일 인스턴스·모달 격리 | 완료 | 두 번째 activation 전달, 강제 종료 없음. modal에서 F2/F3/F8/Escape와 playlist key 격리 | 새 MSIX 전체 회귀 | +| LF-022 | P2 | 영구 운영 로그/bridge 오류 | 완료 | bounded/redacted log와 toast, malformed payload가 송출 상태를 바꾸지 않음 | authoritative Network Monitoring과 함께 판독 | +| LF-023 | P1 | NXT 저장 playlist restore | 외부 데이터 정합성 | stale code를 제목/current identity로 재검증하는 닫힌 경로는 완료. 현재 `v_all_stock` 결과가 비어 preview를 fail closed | DBA/data 정합성 없이는 direct-table 추정 금지 | -## 활성 PLAY_LIST 2,213행 read-only 재분류 +## DB 검증 상세 -운영 Oracle의 활성 프로그램 정의 23개와 연결된 2,213행을 원문을 출력하지 않는 -감사로 다시 분류했다. 원본 `PList.data_load`의 page 공백 제거를 제외하면 모든 -필드를 그대로 보존했고, 현재 persistence 결과와 2,213/2,213 일치했다. +### Read-only scene smoke -| route | 건수 | 현재 결과 | +실제 Oracle/MariaDB에서 필수 scene selector **33/33**과 전체 query 58건이 성공했다. +이 검증은 실제 DB → 명시 DTO → scene mutation 경로를 증명한다. `.t2s`, 영상·배경 자산, +K3D 명령 또는 PGM 화면 검증을 대신하지 않는다. + +### 개발 DB write smoke + +production service/executor와 충돌 격리된 임시 식별자를 사용했다. + +| 시나리오 | 개수 | 검증 | |---|---:|---| -| 기존 닫힌 매퍼 | 1,250 | hash 고정 원본 INI와 현재 exact signature로 selection 1,250개 매핑 | -| 업종 fixed 닫힌 매퍼 | 24 | selection 24개 매핑 | -| 비교 fresh identity | 99 | selection 74개 매핑, 시장 identity 유실 25개 차단 | -| 해외지수 candle fresh identity | 61 | selection 61개 매핑 | -| NXT theme fresh identity | 48 | validated preview empty 48 차단 | -| KRX theme fresh identity | 587 | current-code remap 94, identity 없음 271, 저장 행 무효 16, preview empty 193, 지원 불가 action 13 | -| 해외종목 fresh identity | 78 | exact 입력명에서 unique symbol/nation을 correlated materialize해 78개 매핑 | -| GraphE fresh materialization | 36 | 별도 double-read row-version·stock identity·cut 계약으로 22개 매핑, 저장값 무효 9·identity 중복 5 차단. FSell/VI PList행은 0 | -| unique closed mapper 없음 | 30 | 원본 328-action runtime hash와 현재 exact signature 중 단일 후보가 없어 nearest scene 추정 없이 차단 | -| 합계 | 2,213 | 기본 감사 `selectionMapped=1,581`/차단 632; 별도 GraphE proof를 합치면 `selectionMapped=1,603`/차단 610 | +| GraphE `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT` | 4 | create, fresh readback, delete, absence | +| PList/AList named playlist | 2 | definition/items save, fresh readback, delete, absence | +| ThemeA KRX | 1 | create, preview/readback, delete, absence | +| ThemeA NXT | 1 | create, preview/readback, delete, absence | +| EList expert | 1 | create, preview/readback, delete, absence | +| 합계 | **9** | **PASS**, 잔여 0 | -`unique=2,213`은 전부 송출 가능하다는 뜻이 아니라 모든 행이 단일 route의 -selection 결과 또는 명시적 차단 사유를 가졌다는 뜻이다. `selectionMapped`도 -장면 데이터·자산, package context, PREPARE 또는 PGM 성공이 아니다. 이들은 집계 -밖의 별도 gate다. KRX/NXT theme은 -원본 `s5074/s5077/s5088`처럼 제목 exact lookup을 사용하며, 저장 code를 가장 가까운 -현재 code로 추정하지 않는다. 비교 25행도 현재 이름이 우연히 유일하더라도 저장 -행이 시장을 보존하지 않았으므로 계속 차단한다. 상세 SQL 경계와 결과는 -[`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)에 기록했다. +write는 자동 재시도하지 않는다. `OutcomeUnknown`이면 cleanup mutation이나 다음 시나리오를 +보내지 않는 구현·테스트 경계를 유지한다. 이 결과는 개발 DB 검증이며 운영 DB에 임의 쓰기를 +허용하거나 수행했다는 뜻이 아니다. -## s6001 asset 검사 보강 기준 +## 기존 PLAY_LIST read-only 재분류 -`s6001` 해외지수 target은 다음 13개 파일을 요구한다. +실제 Oracle 활성 정의 23개와 연결된 2,213행은 원문의 7필드, 한국어, enabled, +ITEM_INDEX와 원본의 page 공백 제거 규칙을 보존했다. -- `Video\20201008_미국.vrv` -- `Video\20201008_독일.vrv` -- `Video\20201008_영국.vrv` -- `Video\20201008_프랑스.vrv` -- `Video\20201008_일본.vrv` -- `Video\20201008_중국.vrv` -- `Video\20201008_홍콩.vrv` -- `Video\20201008_대만.vrv` -- `Video\20201008_싱가포르.vrv` -- `Video\20201008_태국.vrv` -- `Video\20201008_필리핀.vrv` -- `Video\20201008_말레이시아.vrv` -- `Video\20201008_인도네시아.vrv` +| route | 건수 | 결과 | +|---|---:|---| +| 기존 닫힌 매퍼 | 1,250 | selection 1,250 | +| 업종 | 24 | selection 24 | +| 비교 | 99 | selection 74, 시장 identity 유실 25 차단 | +| 해외지수 candle | 61 | selection 61 | +| NXT theme | 48 | current preview empty 48 차단 | +| KRX theme | 587 | current-code 94, 나머지 identity/preview/문법/지원 경계로 차단 | +| 해외종목 | 78 | selection 78 | +| GraphE | 36 | fresh materialization 22, 값/identity 14 차단 | +| unique closed mapper 없음 | 30 | nearest-scene 추정 없이 차단 | +| 합계 | **2,213** | 통합 selection 1,603 / 명시 차단 610 | -검사 보강은 다음 조건을 만족해야 한다. +`selectionMapped`는 scene asset, PREPARE나 PGM 성공 수치가 아니다. 상세는 +[`NAMED_PLAYLIST_READ_AUDIT.md`](NAMED_PLAYLIST_READ_AUDIT.md)를 따른다. -1. `.t2s` alias 결과와 asset 결과를 별도 필드로 보고한다. -2. 상대 경로만 허용하고 Cuts root 탈출을 거부한다. -3. 파일 존재, 0바이트 여부, reparse point를 검사한다. -4. 누락된 상대 경로와 영향을 받는 builder를 운영자가 알 수 있게 하되 secret이나 외부 절대 경로를 출력하지 않는다. -5. asset이 없는 현재 장비에서는 전체 migration 완료가 아니라 `외부자산 필요`로 끝나야 한다. -6. 완전 fixture, 단일 누락, 복수 누락, 0바이트, reparse fixture를 자동화한다. +## 외부자산 목록 -권장 검증 명령 형태: +45개 active alias의 `.t2s`는 존재한다. 별도 종속 asset 14개만 제공되지 않았다. -```powershell -powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\Test-LegacyCutCoverage.ps1 ` - -CutRoot "<승인된 읽기 전용 Cuts root>" -``` +- `s5006`: `Video\큐브배경.vrv` +- `s6001`: + - `Video\20201008_미국.vrv` + - `Video\20201008_독일.vrv` + - `Video\20201008_영국.vrv` + - `Video\20201008_프랑스.vrv` + - `Video\20201008_일본.vrv` + - `Video\20201008_중국.vrv` + - `Video\20201008_홍콩.vrv` + - `Video\20201008_대만.vrv` + - `Video\20201008_싱가포르.vrv` + - `Video\20201008_태국.vrv` + - `Video\20201008_필리핀.vrv` + - `Video\20201008_말레이시아.vrv` + - `Video\20201008_인도네시아.vrv` +- 기본 배경: sibling `배경\기본.vrv` -검사 보강 전의 `Missing=0`은 `.t2s` alias만 의미하며 asset-ready 증거로 사용하면 안 된다. +이 파일들의 부재는 code/build 실패가 아니라 **외부자산 미제공**이다. 검사기는 존재, +0바이트, root escape, reparse와 manifest 중복을 검사하고 누락 경로와 영향 builder만 +redacted 형태로 보고한다. -## 원본 I/O 조사에서 제외 판정한 항목 +## 원본 I/O에서 의도적으로 제외한 항목 -- 활성 Print/Excel/FarPoint export/import 호출은 발견되지 않았다. -- `MainForm.cs:1767-1772,1796-1805`의 CSV playlist save/load는 주석 처리돼 있다. -- `MainForm.GetThumnail`과 preview watcher/`SaveImageFile` 호출도 활성 경로가 아니다. -- `s8086`은 scene builder는 존재하지만 원본 MainForm active alias가 없어 의도적으로 unreachable이다. 이를 UI 누락으로 계산하지 않는다. -- FarPoint의 표·선택·정렬·이동 자체는 Web table/workflow로 변환할 수 있지만, 변환 UI가 존재한다는 사실만으로 DB write나 PGM 동등성이 완료되지는 않는다. +- 활성 Print/Excel/FarPoint export/import 호출은 원본에서 발견되지 않았다. +- CSV playlist save/load, thumbnail watcher와 `SaveImageFile`은 주석 또는 미호출 경로다. +- `s8086`은 builder는 있으나 원본 MainForm active alias가 없는 diagnostic route다. +- 위 dead/hidden 경로를 새 활성 기능으로 만들지 않는다. -## 기존 완료 문서 교정 후속 +## 최종 증적 기록 -다음 문서는 구현 존재, 자동 계약 테스트, 실데이터 read smoke, 외부 asset readiness, 실제 DB write, 실제 PGM 검증을 서로 다른 열로 분리해야 한다. +확정된 증적과 최신 전체 회귀의 기록 위치는 +[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)다. -| 문서 | 교정할 표현 | 후속 교정 내용 | -|---|---|---| -| `docs/MIGRATION.md:69-74` | MainForm/UC1~UC7과 전용 화면을 포괄적으로 `완료`로 표현 | `화면·bridge 구현`과 `운영 동등성 검증`을 분리한다. PList 한국어 복원, s6001 asset, 원본 파일 one-time import, DB write, 장면별 PGM 상태를 이 문서와 맞춘다. | -| `docs/OPERATOR_UI_PARITY.md:48-62` | 다수 화면을 `화면 통합 완료`로 표현 | 해당 표현은 구조적 UI 도달성만 뜻한다고 명시하고 DB-W/PGM 미검증을 상단 요약과 각 행에 유지한다. 완료 판정은 실제 검증 열까지 충족할 때만 사용한다. | -| `docs/OPERATOR_UI_PARITY.md:75,111-113,214-218` | PList와 비교쌍 load/save를 완료로 표현 | PList 한국어 실DB 복원과 `종목비교.dat` one-time import를 별도 미완료 항목으로 추가한다. | -| `docs/OPERATOR_UI_PARITY.md:77` | F2/F3 배경을 완료로 표현 | 별도 sibling `배경` root와 `기본.vrv` 코드/자동 검증은 반영됐다. 실제 자산이 없으므로 package/PGM 완료로 확대하지 않는다. | -| `docs/SCENE_EQUIVALENCE.md:17,98` | 전체 real-data smoke 및 s6001을 asset 경고 없이 통과로 표현 | DB→DTO→mutation smoke와 실제 asset preflight를 분리하고 s6001 국가 영상 13개를 `외부자산 필요`로 기록한다. | -| `docs/PLAYOUT_OPERATIONS.md:86,94` | s5025 환경 변수 안내와 alias-only cut 검사 | 정상 앱의 private manual store 경로와 one-time import 정책을 정확히 설명한다. cut coverage 결과에 asset 검사 상태를 추가한다. | -| `docs/PLAYOUT.md:97,348-366` | Web 배경 변경 불가 설명 및 자동 검증을 35개 완료 근거로 사용 | F2/F3 native picker의 실제 경계를 반영한다. 5001/5074 PGM 성공과 나머지 scene 자동 검증을 명확히 분리한다. | +다음 값은 추측하거나 이전 package 결과를 재사용하지 않는다. -이 교정은 기존 구현 성과를 취소하는 것이 아니라, `구현됨`과 `실제 방송 동등성 완료`를 같은 의미로 사용하지 않기 위한 것이다. +| 항목 | 상태 | +|---|---| +| Debug/Release x64 전체 테스트 개수 | **PASS** — .NET 각 2,394/2,394, Web 435/435 | +| 새 Release x64 MSIX SHA-256·서명 | `FC4EEE1509675E643D2B4CB435E279CAC4611B28534048D9B9EE611E361E0E1F`, Valid, `CN=Comtrophy` | +| 새 package-context 실행 | **PASS** — DryRun과 개발 Live, 정상 종료 | +| 개발 Tornado2 Network Monitoring/PGM 결과 | **PASS** — `5001` → `5074` 1/2 → 2/2 → TAKE OUT/BYE | +| Gitea 반영 commit | **PASS** — 최종 이관·검증 문서 포함 commit을 `origin/main`에 fast-forward push | -## 완료 판정에 필요한 최종 증거 - -1. s5006/s6001 포함 모든 reachable scene의 asset preflight 결과 -2. PList 한국어 `DC_TITLE`/`LIST_TEXT` 실DB read 비교 -3. 기존 `종목비교.dat`, FSell, VI 데이터의 안전한 one-time migration 결과 -4. 승인된 테스트 데이터로 수행한 GraphE/PList/ThemeA/EList 및 manual file write read-back -5. Debug/Release x64 회귀와 Release MSIX package-context 실행 -6. 승인된 scene·회차의 Network Monitoring 명령, callback, PGM 화면 -7. timeout·OutcomeUnknown에서 재시도하지 않았다는 운영 기록 - -이 증거가 갖춰지기 전에는 전체 원본 UI 또는 35개 scene의 동등성 목표를 완료 처리하지 않는다. +원본 저장소는 최종 검증과 배포 이후에도 계속 읽기 전용으로 유지한다. diff --git a/docs/LEGACY_PARITY_REBASELINE.md b/docs/LEGACY_PARITY_REBASELINE.md index d07393a..c932ff3 100644 --- a/docs/LEGACY_PARITY_REBASELINE.md +++ b/docs/LEGACY_PARITY_REBASELINE.md @@ -1,101 +1,141 @@ # 원본 호환 마이그레이션 기준선 -## 결정 +기준일: 2026-07-15 -기존 저장소와 검증된 `Core / Infrastructure / Playout`은 유지한다. 기존 루트 WinUI 앱은 비교용 프로토타입으로 보존하고, 원본 UI와 이벤트를 먼저 재현하는 `MBN_STOCK_WEBVIEW.LegacyParityApp`을 같은 솔루션에서 병행 개발한다. +## 현재 결정 -WebView는 HTML 컨트롤 표시와 사용자 입력 전달만 담당한다. 검색 정책, 선택 상태, 플레이리스트 필드, 장면 alias, PageN 및 송출 상태의 권위는 C# Application 계층에 둔다. +마이그레이션의 실행 대상은 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. 이 앱은 +`.NET 8 / C# / WinUI 3 / Windows App SDK 1.8 / WebView2 / Windows x64 / MSIX`로 +구성된 Windows 데스크톱 프로그램이다. WebView2는 앱에 포함된 로컬 HTML/CSS/JavaScript를 +표시하는 컨트롤이며, 별도 웹 서버에 배포하는 웹사이트가 아니다. -## 기준 원본 +원본 WinForms UI의 화면 구성과 클릭 이벤트를 먼저 동등하게 옮긴 뒤 개선한다. 기존에 +검증된 `Core / Infrastructure / Playout`은 재사용하며, 원본 업무 규칙과 송출 권위는 +C#에 둔다. -- 읽기 전용 저장소: `C:\Users\MD\source\repos\MBN_STOCK_N` -- 조사 시 HEAD: `309286309eaf20b58172247263f575753a860700` -- 실제 실행 컷 목록: `MBN_STOCK_N\bin\Debug\Res\종목.ini` -- 실행 컷 목록 SHA-256: `45DFB1804828F0E4A45F73D681AF0709CE5662872FAA49CFC9F7A0B940409E20` -- 실행 컷 목록은 구분행 2개를 포함한 33개 물리행이며 실제 컷은 31개다. -- 3번 컷은 고객 화면과 실행본을 따라 `1열판기본_시간외단일가`를 사용한다. 소스 `Res`의 `시간외종가`는 기준으로 사용하지 않는다. +## 기준 원본과 보존 원칙 -원본 작업 트리에는 기존 수정이 있으므로 커밋 SHA만으로 동등성을 판단하지 않는다. 원본은 계속 읽기 전용으로 취급한다. +- 읽기 전용 원본: `C:\Users\MD\source\repos\MBN_STOCK_N` +- 원본 저장소와 `MBN_STOCK_N\MBN_STOCK_N\bin\Debug\Cuts`는 분석·검증에만 사용한다. +- 원본 파일, 원본 설정, 실제 컷과 자산을 수정하거나 새 저장소에 복사하지 않는다. +- 벤더 DLL, K3D 라이선스, 인증서, DB 비밀번호, 실제 컷·배경 파일과 로컬 운영 설정은 + Git/MSIX에 넣지 않는다. +- 실행 컷 목록은 구분행 2개를 포함한 물리행 33개와 실제 종목 컷 31개다. +- 원본 scene inventory는 35개 builder이며, MainForm에서 도달 가능한 runtime은 34개 + builder와 45개 active alias다. `s8086`은 원본과 같이 무alias 진단 전용이다. -## 신규 프로젝트 +## 실행 구조와 권위 경계 -| 프로젝트 | 책임 | +| 계층 | 책임 | |---|---| -| `MBN_STOCK_WEBVIEW.LegacyApplication` | 원본 MainForm/UC 상태, 이벤트 및 업무 규칙 | -| `MBN_STOCK_WEBVIEW.LegacyBridge` | 허용된 WebView 입력 intent와 표시 snapshot 계약 | -| `MBN_STOCK_WEBVIEW.LegacyParityApp` | WinUI 3 창, WebView2, DB composition root, 별도 개발 MSIX | -| `LegacyApplication.Tests` | 원본 이벤트 및 결과 DTO 동등성 테스트 | -| `LegacyBridge.Tests` | strict JSON과 Web 권한 경계 테스트 | -| `LegacyWeb.Tests` | Visual Studio에서 항상 실행되는 Web thin-client 계약 테스트 | +| `MBN_STOCK_WEBVIEW.LegacyApplication` | 원본 MainForm/UC/Form 상태, 클릭 이벤트, playlist와 업무 규칙의 C# 권위 | +| `MBN_STOCK_WEBVIEW.LegacyBridge` | WebView가 보낼 수 있는 intent의 strict JSON allowlist와 표시 snapshot 계약 | +| `MBN_STOCK_WEBVIEW.LegacyParityApp` | WinUI 3 창, 로컬 WebView2 UI, DB composition root, 송출 연결과 개발 MSIX | +| `MBN_STOCK_WEBVIEW.Core` | DTO, scene resolver/builder, PageN과 `LegacyPlayoutWorkflow` | +| `MBN_STOCK_WEBVIEW.Infrastructure` | Oracle/MariaDB와 신뢰 파일 저장소 구현 | +| `MBN_STOCK_WEBVIEW.Playout` | `IPlayoutEngine` 뒤의 K3D/Tornado2 어댑터, STA 큐와 안전 게이트 | -개발 패키지 identity는 `Wickedness.MBNStockWebView.LegacyParity`다. 운영 identity `Wickedness.MBNStockWebView`를 대체하지 않는다. +WebView는 표시와 사용자 입력 수집만 담당한다. 종목·시장 identity, 7필드 playlist 행, +scene alias, DB mutation, PageN, PREPARED/PROGRAM 상태는 Web에서 임의 생성하거나 +확정할 수 없다. 모든 입력은 `LegacyBridgeProtocol`의 폐쇄형 intent로 파싱되고 +`LegacyOperatorController`가 다시 검증한 뒤 실행된다. -## 첫 수직 흐름 상태 +## 이관된 원본 UI와 이벤트 -구현됨: +- 원본 10개 업무 탭과 탭 순서 변경 +- 모든 탭에서 유지되는 종목 검색, 종목 결과, 31개 컷과 playlist 편집 +- 328개 fixed action, 코스피·코스닥 업종 44개 action, 비교 대상 24개와 비교 9개 action +- UC4 테마, UC6 전문가와 UC7 거래정지의 원본 단일 클릭 동작 +- UC4/UC6 목록 행과 고정 action 트리의 독립 클릭 경계 +- GraphE 네 수동 재무 화면과 행 더블클릭 추가 +- FSell 개인·외국인·기관 5행 입력과 VI 목록·정렬·페이지 입력 +- PList/AList의 목록, 신규 생성, 저장, 불러오기, 삭제와 행 더블클릭 +- ThemeA와 EList의 검색, 생성, 편집, 삭제, 종목 구성과 순서 변경 +- UC5 해외업종·해외종목·해외지수 +- playlist 단일/Ctrl/Shift 선택, enabled, 위/아래 이동, 선택/전체 삭제 +- F2/F3 배경, Fade, NEXT/Page NEXT, TAKE OUT, 종료 확인과 자동 갱신 -- 검색 버튼의 정확한 빈 문자열 no-op과 Enter 빈 검색 허용 -- KOSPI → KOSDAQ → NXT KOSPI → NXT KOSDAQ 순서 및 DB 반환 순서 유지 -- trim, 전체 정렬, 결과 제한 및 wildcard escape를 하지 않는 원본 호환 검색 -- 바인드 파라미터를 통한 SQL injection 방지 -- master snapshot을 이름으로 다시 순회하고 마지막 일치를 채택하는 `jongmokCode` 규칙 -- 33개 물리 컷 행과 31개 display 번호 -- 원본 `ClientSize 1905 x 1015`, `FixedSingle`, 최대화 불가 창 계약 -- 일반/Ctrl/Shift/Ctrl+Shift 선택을 C# 상태로 처리 -- 0.5초 이내, 동일 행, 동일 좌표 조건의 원본 더블클릭 판정 -- 선택 컷 drag/drop과 물리 index 오름차순 추가 -- 원본 7필드 플레이리스트 행과 중복 추가 허용 -- NXT `1열판기본_현재가`만 허용하고 나머지는 원본 경고 표시 -- PREPARE 직전 `LegacyPlaylistEntry`로 변환할 수 있는 31개 컷 행의 alias 매핑 +FarPoint 표 자체는 Web table로 바뀌었지만 원본의 선택, 정렬, 이동, 단일/더블클릭, +저장·불러오기 이벤트 경계는 C# intent와 snapshot으로 유지한다. -아직 이관하지 않음: +## 송출 동작 -- 좌측 수동 입력 GraphE 4개 모달 -- 중앙 UC1~UC7과 모든 탭 이벤트 -- 플레이리스트 선택/삭제/정렬/DB 저장·불러오기 -- 배경, 이동평균, Fade/Update 시간 컨트롤 -- PREPARE/TAKE IN/NEXT/TAKE OUT 실제 연결 -- 운영 DB write와 Tornado2/PGM 검증 +운영자 화면에는 원본처럼 `TAKE IN`, `NEXT`, `TAKE OUT`을 제공한다. 별도 native +`PREPARE` 버튼은 엔지니어링 진단용으로 DOM에 유지하지만 숨겨져 있다. idle 상태에서 +`TAKE IN` 또는 F8을 한 번 실행하면 C#이 같은 게이트 안에서 다음 순서를 정확히 한 번씩 +수행한다. -미이관 버튼은 현재 disabled 상태이며, 연결 상태는 `미연결`로 명시한다. 새 앱은 Tornado2에 연결하지 않는다. +1. 선택 playlist와 page를 C# snapshot으로 고정 +2. 실제 DB/신뢰 파일에서 DTO를 다시 조회 +3. `BeginTransaction` → mutation → `QueryVariables` → `EndTransaction` +4. `Prepare` +5. `TakeIn` + +PREPARE 성공 뒤 TAKE IN이 실패하거나 결과가 불명확하면 prepared 소유권을 유지하고 +자동 재시도하지 않는다. `NEXT`는 같은 scene의 다음 PageN과 다음 enabled playlist 행을 +원본 조건으로 구분한다. timeout과 `OutcomeUnknown`에서는 반대 명령, cleanup 명령 또는 +동일 명령을 추측해 반복하지 않는다. + +기본 송출 모드는 계속 `DryRun`이다. Live는 개발 Tornado2/PGM 설정, 프로세스별 명시적 +환경 승인, scene allowlist, 명령 예산을 모두 통과해야 한다. 이 안전 게이트는 개발 환경에서도 +완화하지 않는다. + +## scene, alias와 PageN 기준선 + +- 원본 scene builder source inventory: **35/35** +- 도달 가능한 active alias: **45개**, 승인된 읽기 전용 Cuts에 대응 `.t2s` 존재 +- PageN: `s5074=5`, `s5077=6`, `s5088=12`개 단위 +- page count: `min(20, ceil(itemCount / pageSize))` +- 빈 결과, 첫/마지막 부분 페이지, 정확한 경계, 20페이지 cap, 21페이지 거부, + 같은 scene Page NEXT와 다음 playlist NEXT를 자동 테스트로 고정 + +장면별 세부 상태는 [`SCENE_EQUIVALENCE.md`](SCENE_EQUIVALENCE.md), 호출 순서는 +[`LEGACY_PLAYOUT_ANALYSIS.md`](LEGACY_PLAYOUT_ANALYSIS.md)를 따른다. + +## DB 검증 기준선 + +실제 Oracle/MariaDB read-only 스모크는 필수 scene selector **33/33**, 전체 query 58건을 +통과했다. 이는 실제 DB → DTO → mutation 경로 검증이며 외부 영상 자산이나 PGM 출력 성공을 +뜻하지 않는다. + +개발 DB write 스모크는 충돌 격리된 임시 식별자로 다음 9개 시나리오의 +create/save → fresh readback → delete → absence를 통과했다. + +- GraphE `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT` 4개 +- PList/AList named playlist 1개 +- ThemeA KRX 1개와 NXT 1개 +- EList/expert 1개 + +스모크는 production executor를 사용하고 write 재시도를 하지 않는다. 결과가 +`OutcomeUnknown`이면 cleanup이나 다음 시나리오를 실행하지 않는 규칙도 유지한다. +DB 비밀번호와 원문 데이터는 문서와 로그에 기록하지 않는다. + +## 외부자산 상태 + +45개 active alias의 `.t2s`는 존재한다. 다만 다음 14개 종속 파일은 현재 승인 Cuts에 +제공되지 않았다. + +- `s5006`: `Video\큐브배경.vrv` 1개 +- `s6001`: 국가별 `Video\20201008_<국가>.vrv` 13개 + +또한 원본 sibling `배경` 디렉터리의 `기본.vrv`도 이 장비에 없다. 이 상태는 scene/UI +코드 누락이나 마이그레이션 실패가 아니라 **Git/MSIX 밖 외부자산 미제공**이다. 자산이 +없으면 해당 경로는 COM 호출 전에 fail closed하고, 다른 파일로 추정 대체하지 않는다. ## Visual Studio 실행 1. `MBN_STOCK_WEBVIEW.sln`을 연다. -2. 솔루션 구성을 `ParityDebug | x64`로 선택한다. -3. `MBN_STOCK_WEBVIEW.LegacyParityApp`을 시작 프로젝트로 지정한다. -4. 실행한다. +2. `x64` 구성을 선택한다. +3. `src\MBN_STOCK_WEBVIEW.LegacyParityApp\MBN_STOCK_WEBVIEW.LegacyParityApp.csproj`를 + 시작 프로젝트로 지정한다. +4. Debug 실행 또는 Release x64 MSIX를 생성한다. -`PrototypeDebug | x64`는 기존 루트 앱을 배포하는 비교용 구성이다. 두 앱은 서로 다른 MSIX identity와 single-instance key를 사용한다. +개발 패키지 identity는 `Wickedness.MBNStockWebView.LegacyParity`이며 기존 운영 패키지 +identity를 대체하지 않는다. -기존 `Release | x64`의 자동 배포 대상은 비교용 루트 앱으로 유지한다. 호환 앱 Release는 두 패키지를 동시에 배포하지 않도록 `MBN_STOCK_WEBVIEW.LegacyParityApp` 프로젝트의 MSIX 생성 명령으로 명시적으로 만든다. +## 2026-07-15 검증 기록 -## 2026-07-15 기준선 검증 - -- `ParityDebug | x64`와 `Release | x64` 솔루션 빌드: 경고 0, 오류 0 -- Debug/Release 각각 C# 테스트 1,980개 통과 - - Core 1,383 / Infrastructure 173 / Playout 393 - - LegacyApplication 17 / LegacyBridge 7 / LegacyWeb 7 -- Node 기반 JavaScript syntax 및 Web 계약 테스트 7개 통과 -- Release x64 개발 MSIX `0.1.0.0` 생성 및 `CN=Comtrophy` 서명 검증 -- MSIX SHA-256: `D3FF9CF1F63E6BC68AA147C0ED47690FE130CF425C0276B3201F763EC5C50A4B` -- 패키지 76개 항목 중 인증서, 비밀번호, 라이선스, `.t2s`, 운영 설정 파일 0개 -- 패키지의 `Web/index.html`, `styles.css`, `app.js`가 소스와 byte 단위로 일치 -- 별도 패키지 전체 이름 `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0`, package family `Wickedness.MBNStockWebView.LegacyParity_qbv3jkvsn3aj0` 설치·실행 성공 -- 패키지 창의 실제 WebView client bounds `1905 x 1015` 확인 -- 격리 WinForms ListView trace로 최초 Shift의 native 선택 `0..현재행`과 별도 pointer 목록의 `-1..현재행`, 같은 행 Shift의 native 선택 유지와 pointer 목록 clear를 확인하고 두 상태를 분리 -- 실제 DB read-only 경로로 `삼성` 검색 → `삼성출판사` 선택 → `현재가` 동일 좌표 더블클릭 → 원본 7필드 플레이리스트 행 생성 확인 -- 검색 시작 직후 정상 창 닫기 경합 검증에서 10초 이내 종료 및 Windows Application 오류 이벤트 0개 - -이 검증에서는 DB write, PREPARE, TAKE IN, NEXT, TAKE OUT 및 Tornado2 연결을 실행하지 않았다. 화면의 송출 버튼도 계속 비활성 상태다. - -## 다음 이관 순서 - -1. 플레이리스트 row 선택·Ctrl/Shift·삭제·전체/개별 enabled -2. 중앙 탭과 UC1부터 원본 이벤트 단위로 수직 이관 -3. 수동 입력 Form과 격리 DB write/read-back -4. C# playlist authority를 기존 `LegacyPlayoutWorkflow`에 연결한 DryRun PREPARE -5. 35개 scene/PageN differential trace -6. 승인된 실제 Tornado2/PGM 검증 - -각 기능은 원본 입력과 신규 입력의 snapshot, DB 파라미터, 플레이리스트 7필드 및 K3D trace를 비교한 뒤 완료 처리한다. +확정된 전체 회귀, 새 Release MSIX SHA-256, package-context DryRun/Live, 개발 +Tornado2 Network Monitoring과 PGM 결과는 +[`LEGACY_PARITY_VALIDATION_20260715.md`](LEGACY_PARITY_VALIDATION_20260715.md)에 +기록했다. 이전 패키지의 숫자나 해시는 현재 결과로 재사용하지 않았다. diff --git a/docs/LEGACY_PARITY_VALIDATION_20260715.md b/docs/LEGACY_PARITY_VALIDATION_20260715.md new file mode 100644 index 0000000..19573e6 --- /dev/null +++ b/docs/LEGACY_PARITY_VALIDATION_20260715.md @@ -0,0 +1,124 @@ +# LegacyParityApp 이관 검증 증적 — 2026-07-15 + +이 문서는 현재 `MBN_STOCK_WEBVIEW.LegacyParityApp`의 구현·통합 최종 검증 결과를 +한곳에 모은다. 원본 +`C:\Users\MD\source\repos\MBN_STOCK_N`은 모든 조사와 테스트에서 읽기 전용으로 +취급했다. + +## 제품 형태와 권위 경계 + +| 항목 | 결과 | +|---|---| +| 제품 형태 | `.NET 8 / C# / WinUI 3 / Windows App SDK 1.8 / WebView2 / x64 / MSIX` Windows 데스크톱 앱 | +| 웹사이트 여부 | 웹사이트가 아님. WinUI 3 프로세스가 패키지의 로컬 Web 자산을 WebView2로 표시 | +| 업무 권위 | `LegacyOperatorController`의 C# 상태와 검증된 DTO가 권위 | +| Web 경계 | `LegacyBridgeProtocol`의 strict JSON intent allowlist; Web은 DB identity, scene alias, playlist 또는 송출 상태를 확정하지 않음 | +| 벤더 경계 | COM/K3D/Tornado 구현은 `IPlayoutEngine` 뒤에 격리하고 STA 큐와 안전 게이트 유지 | +| 기본 송출 모드 | `DryRun`; Live는 별도 개발 설정, 프로세스별 승인, allowlist와 명령 예산 필요 | +| 불명확 결과 | timeout/`OutcomeUnknown`에서 자동 재시도·반대 명령·추측 cleanup 금지 | + +## 원본 UI와 클릭 이벤트 + +| 범위 | 현재 증적 | +|---|---| +| 업무 탭 | 원본 10개 탭, identity 기반 활성화와 순서 변경 | +| 종목 영역 | 모든 탭에서 유지되는 종목 검색, 결과, 31개 컷과 playlist 추가 | +| 고정/업종/비교 | fixed 328개, 업종 44개, 비교 대상 24개와 action 9개를 폐쇄형 inventory로 고정 | +| 독립 클릭 | UC4 테마, UC6 전문가, UC7 거래정지의 원본 단일 클릭과 UC4/UC6 행별 독립 처리 | +| 전용 입력 | GraphE, FSell, VI, PList, AList, ThemeA, EList를 WebView 화면과 C# intent로 연결 | +| 해외 | UC5 해외업종·해외종목·해외지수 조회와 playlist 생성 | +| playlist | 단일/Ctrl/Shift 선택, enabled, 위/아래, 선택/전체 삭제, drag/batch 경계 | +| FarPoint 대체 | Web table로 표현하되 원본 클릭·더블클릭·정렬·이동·저장 이벤트 의미 유지 | + +상세 이벤트 대조는 [`OPERATOR_UI_PARITY.md`](OPERATOR_UI_PARITY.md)를 따른다. + +## 송출 흐름과 장면 + +| 항목 | 결과 | +|---|---| +| 원클릭 TAKE IN | idle에서 보이는 `TAKE IN`/F8 한 번으로 `PrepareAndTakeInAsync`가 page load 1회, PREPARE 1회, TAKE IN 1회를 같은 게이트에서 수행 | +| native PREPARE | 엔지니어링 진단용 control/intent는 유지하되 운영자 UI에서는 `hidden` | +| 실패 경계 | PREPARE 후 TAKE IN 실패/unknown 시 prepared state 보존, 자동 재시도 없음 | +| 장면 기준선 | 원본 builder **35/35** inventory와 mutation/runtime 자동 기준선 통과 | +| active alias | **45개** alias의 대응 `.t2s`가 승인된 읽기 전용 Cuts에 존재 | +| PageN | 5·6·12개 단위, 최대 20페이지, 경계·마지막 부분 페이지·21페이지 거부 테스트 | +| NEXT | 같은 scene의 Page NEXT와 다음 enabled playlist 행 NEXT를 원본 조건으로 구분 | + +## 실제 DB 검증 + +| 검증 | 결과 | +|---|---| +| Oracle/MariaDB read-only | **PASS** — 필수 scene selector 33/33, 전체 query 58건 | +| GraphE write | **PASS** — `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT` 각 create, fresh readback, delete, absence | +| PList/AList write | **PASS** — named playlist 정의·항목 create/save, fresh readback, delete, absence | +| ThemeA KRX write | **PASS** — create, preview/readback, delete, absence | +| ThemeA NXT write | **PASS** — create, preview/readback, delete, absence | +| EList write | **PASS** — expert create, preview/readback, delete, absence | + +개발 DB write는 총 9개 충돌 격리 시나리오다. production service/executor를 사용했으며 +인증정보나 DB 원문을 출력하지 않았다. write 결과가 불명확한 경우에는 재시도와 cleanup을 +하지 않는 구현·테스트 경계를 유지한다. + +## 외부자산 판정 + +| 자산 | 상태 | 판정 | +|---|---|---| +| 45 active alias `.t2s` | 존재 | alias 준비 완료 | +| `s5006` `Video\큐브배경.vrv` | 1개 없음 | 외부자산 미제공; 코드 실패 아님 | +| `s6001` 국가별 영상 | 13개 없음 | 외부자산 미제공; 코드 실패 아님 | +| sibling `배경\기본.vrv` | 없음 | 외부자산 미제공; F2/F3/default background는 fail closed | + +누락 자산은 Git 또는 MSIX에 임의로 넣지 않는다. 제공되면 같은 trusted-root, +0-byte, reparse, root-escape 검사를 통과한 뒤 해당 scene만 다시 검증한다. + +## 개발 Tornado2와 PGM 실측 + +2026-07-15 개발 Tornado2 `2.7.5.49 x64 DSX10.0 S.R2`의 단일 `30001/tcp` +listener와 K3D x64 등록·라이선스를 고정한 뒤, 설치된 MSIX package-context 앱에서 다음 +시퀀스를 실행했다. + +1. 삼성전자 검색 → `5001` playlist 추가 +2. 원본과 같은 fixed leaf **더블클릭**으로 VI 수동 목록 9종목 조회 → `5074` 2페이지 추가 +3. `PREPARE 5001 page 1` → `TAKE IN` → `OnScenePlayed` → 자동 갱신 정확히 1회 +4. `NEXT`로 다음 playlist 항목 `5074 page 1/2` → 자동 갱신 정확히 1회 +5. 같은 scene `Page NEXT`로 `5074 page 2/2`의 마지막 4종목 송출 +6. `TAKE OUT All` → `IDLE` → 정상 종료 확인 → `BYE` + +첫 실측과 화면 증적용 known-outcome 재현은 모두 `OutcomeUnknown=false`로 끝났다. 불명확 +결과 뒤 명령 재시도는 없었다. Network Monitoring 최종 원문은 1,863줄, +299,616문자, SHA-256 +`7CDB183A2518EF47E5CC3819C2EB14C06492DE0C8E018350A0633E0FBBB5B994`다. 두 실측을 +합쳐 `LOAD_SCENE` 4/4, `BEGIN_TRANSACTION` 12/12, `VALUE_SET` 258/258, +`VISIBLE` 568/568, `SCENE_PREPARE` 12/12, `PLAY` 10/10, `STOPAL` 2/2, +`UNLOAD_SCENE` 4/4가 요청/성공으로 일치했고 `SCENE_PLAYED` 10회를 수신했다. 정상 +종료에서는 `BYE` 요청 1회가 기록됐고 이후 established 연결과 앱/CDP process는 0개다. + +화면 증적은 300프레임 중 72프레임이 검은 기준 화면과 달랐다. `5001` 삼성전자 화면, +`5074` page 1의 5종목, page 2의 4종목을 직접 확인했고 TAKE OUT 뒤 기준 검은 화면으로 +복귀했다. 프레임 원본과 Network Monitoring 원문은 실제 컷·운영자료를 Git에 넣지 않는 +정책에 따라 로컬 `artifacts/final-validation-20260715`에만 보관한다. + +## 최종 전체 회귀 기록 + +아래 값은 모든 병렬 변경과 fixed-leaf 더블클릭 회귀 테스트가 합쳐진 뒤 다시 실행한 +최종값이다. + +| 항목 | 최종 결과 | +|---|---| +| Debug x64 전체 빌드·테스트 | **PASS** — .NET 2,394/2,394, VS 2026 `ParityDebug|x64`, 경고 0·오류 0 | +| Release x64 전체 빌드·테스트 | **PASS** — .NET 2,394/2,394, VS 2026 `Release|x64`, 경고 0·오류 0 | +| Web/LegacyParity 전체 테스트 | **PASS** — 47파일, 435/435 (`Test-WebPlayout` 415 + thin-client 20) | +| Release x64 MSIX 경로·버전 | `artifacts/final-validation-20260715/package/MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64_Test/MBN_STOCK_WEBVIEW.LegacyParityApp_0.1.0.0_x64.msix`, `0.1.0.0`, 15,200,545 bytes | +| 새 MSIX SHA-256·서명 | `FC4EEE1509675E643D2B4CB435E279CAC4611B28534048D9B9EE611E361E0E1F`, **Valid**, `CN=Comtrophy` | +| 설치된 package-context 실행 | **PASS** — `Wickedness.MBNStockWebView.LegacyParity_0.1.0.0_x64__qbv3jkvsn3aj0`, DryRun 전체 흐름과 Live 개발 PGM 실행, 정상 종료 | +| 개발 Tornado2 Network Monitoring | **PASS** — HELLO, transaction/mutation/query/prepare/play, event, unload, STOPAL, BYE와 연결 해제 확인 | +| 개발 PGM PREPARE → TAKE IN → NEXT/Page NEXT → TAKE OUT | **PASS** — `5001` → `5074` 1/2 → `5074` 2/2 → 검은 화면 복귀 | +| Gitea main commit/push | **PASS** — 이 최종 검증 문서를 포함한 커밋을 `origin/main`에 fast-forward push | + +## 판정 경계 + +- 35/35 자동 장면 동등성은 모든 장면의 실제 PGM 출력 완료를 뜻하지 않는다. +- 33/33 DB selector 통과는 외부 영상/배경 자산 준비를 뜻하지 않는다. +- 개발 DB 9개 write 시나리오 통과는 운영 DB에 쓰기를 허용하거나 수행했다는 뜻이 아니다. +- 개발 Tornado2/PGM은 안전 게이트를 유지한 별도 통합 검증 대상이다. +- 원본 저장소, 실제 Cuts, 라이선스와 인증정보는 계속 읽기 전용 또는 Git 외부로 유지한다. diff --git a/docs/OPERATOR_UI_PARITY.md b/docs/OPERATOR_UI_PARITY.md index 3c75413..9332da4 100644 --- a/docs/OPERATOR_UI_PARITY.md +++ b/docs/OPERATOR_UI_PARITY.md @@ -1,288 +1,175 @@ # 운영자 UI 동등성 인벤토리 -## 문서 목적과 판정 기준 +기준일: 2026-07-15 -이 문서는 원본 WinForms 운영 화면의 사용자 동작을 새 WinUI 3/WebView 구현에 1:1로 연결한다. 장면 builder가 존재하는 것, 네이티브 bridge와 workflow 모듈이 존재하는 것, 실제 Web 화면에서 운영자가 사용할 수 있는 것은 서로 다른 완료 조건으로 취급한다. +## 제품과 판정 기준 -상태 표기는 다음과 같다. 아래 기존 행의 `화면 통합 완료` 표현도 `화면/계약 구현`과 같은 구조적 도달성만 뜻하며, 운영 동등성 완료로 읽지 않는다. +현재 이관 앱은 웹사이트가 아니라 `.NET 8 / C# / WinUI 3 / WebView2` Windows 데스크톱 +프로그램인 `MBN_STOCK_WEBVIEW.LegacyParityApp`이다. WebView2는 패키지 안의 로컬 +HTML/CSS/JavaScript를 표시한다. -| 상태 | 의미 | +Web은 원본 UI를 표현하고 사용자 intent를 전달한다. 원본 이벤트의 상태 전이, DB identity, +playlist, PageN과 송출 상태는 `LegacyOperatorController`의 C# snapshot이 권위다. Web 메시지는 +`LegacyBridgeProtocol`의 strict JSON allowlist에 없는 필드·값·명령을 거부한다. + +이 문서의 판정은 다음을 구분한다. + +| 판정 | 의미 | |---|---| -| 화면/계약 구현 | 현재 `Web/index.html`과 `Web/app.js`에서 검색·선택·플레이리스트 추가 또는 저장 동작에 도달하며 native bridge·workflow 계약이 있다. | -| 화면/계약 구현 / 실환경 검증 대기 | 화면, native bridge, workflow와 자동 테스트까지 연결됐지만 운영 DB write, 기존 데이터 identity/문자 복원 또는 해당 경로의 실제 PGM 확인을 수행하지 않았다. | -| 운영 동등성 일부 검증 | 승인된 제한 데이터·scene·회차만 실제 DB/PGM으로 확인했다. 확인하지 않은 범위로 확대하지 않는다. | -| 운영 동등성 완료 | 원본과 같은 입력·저장·재조회·identity·PGM 결과를 해당 기능 범위 전체에서 확인한 상태다. 현재 전체 UI에 이 판정을 적용하지 않는다. | +| UI/이벤트 이관 | 원본 컨트롤과 클릭 이벤트가 실제 LegacyParityApp 화면 → strict bridge → C# workflow로 연결됨 | +| 자동 동등성 | 원본 inventory·DTO·mutation·호출 순서·경계값을 자동 테스트로 비교함 | +| 개발 DB 통합 | 실제 개발 Oracle/MariaDB에서 create/readback/delete/absence를 확인함 | +| 개발 PGM 통합 | 개발 Tornado2 Network Monitoring과 PGM 화면을 함께 확인함 | +| 외부자산 필요 | 코드 경로는 있으나 Git/MSIX 밖의 실제 영상·배경이 제공되지 않음 | -`DB-W`는 운영 데이터베이스에 실제 쓰기를 수행해 확인했는지를 뜻한다. `PGM`은 실제 Tornado2 PGM 출력으로 해당 UI 경로를 확인했는지를 뜻한다. fake executor, DryRun, SQL 계약 테스트는 자동 테스트 근거이지 실제 DB-W 또는 PGM 검증으로 계산하지 않는다. +## 원본 보존 -기능별 미적용·적용 중·외부자산 필요 판정과 완료에 필요한 증거는 [`LEGACY_FEATURE_AUDIT.md`](LEGACY_FEATURE_AUDIT.md)를 따른다. +- 원본 `C:\Users\MD\source\repos\MBN_STOCK_N`은 읽기 전용으로만 분석했다. +- 원본 `Cuts`도 읽기 전용 검증에만 사용하며 수정·복사하지 않는다. +- 비밀번호, 라이선스, 벤더 DLL, 인증서, 실제 컷·영상·배경과 로컬 설정은 Git에 넣지 않는다. -## 원본 보존 상태 +## 원본 실행 action inventory -- 원본 저장소는 읽기 전용으로만 분석했고 파일을 수정하지 않았다. -- 분석 시작 시 원본에 이미 존재하던 dirty 파일은 `MBN_STOCK_N/MainForm.cs`, `MBN_STOCK_N/RES/MmoneyCoder.ini` 두 개였다. 두 파일의 기존 변경을 그대로 보존했다. -- 이 문서에는 비밀번호, 실제 방송 데이터, 자산·경로 해시를 기록하지 않는다. +동적 테마·전문가 DB 행과 전용 편집기의 데이터 행은 아래 고정 수량에 중복해 더하지 않는다. -## 412개 실행 action의 고정 분해 +| 집합 | 원본 UI | 수량 | 현재 경계 | 상태 | +|---|---|---:|---|---| +| fixed action | MainForm 해외·환율·지수와 UC1 | **328** | `LegacyFixedActionCatalog` + strict action/section intent | 이관 | +| 업종 action | UC1 + UC2 | **44** | `LegacyIndustryActionCatalog` + C# section batch | 이관 | +| 종목 컷 | MainForm 좌측 검색·컷 | **31** | `LegacyStockCutCatalog` | 이관 | +| 비교 action | UC1 + UC3 | **9** | 24개 target + `LegacyComparisonWorkflowController` | 이관 | +| 업무 탭 | MainForm TabControl | **10** | identity 기반 활성화·순서 변경 | 이관 | -412는 다음 네 집합의 합이다. 동적 테마·전문가 목록과 GraphE 편집 행 수는 이 숫자에 중복해 더하지 않는다. +328개 fixed action 중 수동 전제 6개는 임의 DTO로 만들지 않는다. 개인·외국인·기관 +순매도 3개는 FSell의 신뢰된 5행 입력으로, 5단·6종목·12종목 트리의 VI placeholder 3개는 +원본 VIList가 만든 `s5074/5074` 5개 단위 PageN으로만 활성화한다. -| 집합 | 원본 UI | 수량 | 새 구현 | 자동 근거 | 상태 | -|---|---|---:|---|---|---| -| 고정 action | `MainForm`의 해외·환율·지수 탭과 `Control/UC1.cs` | 328 | `Web/legacy-fixed-workflow.js` | `tests/Web/legacy-fixed-workflow.test.cjs`가 328개, 직접 생성 가능한 322개, 수동 전제 6개를 고정한다. | 화면 통합 완료. 수동 6개는 아래 대체 경계를 통과해야 한다. | -| 업종 action | `Control/UC1.cs` + `Control/UC2.cs`의 코스피·코스닥 | 44 | `Web/industry-workflow.js`의 시장별 22개 | `tests/Web/industry-workflow.test.cjs` | 화면 통합 완료 | -| 종목 action | `MainForm` 좌측 종목 검색·컷 목록 | 31 | `Web/operator-workflow.js` | `tests/Web/operator-workflow.test.cjs`가 실행 자산 순서의 31개를 고정한다. | 화면 통합 완료 | -| 비교 action | `Control/UC1.cs` + `Control/UC3.cs` | 9 | `Web/comparison-workflow.js` | `tests/Web/comparison-workflow.test.cjs`가 24개 비교 대상과 9개 action을 별도로 고정한다. | 화면 통합 완료 | -| 합계 | | **412** | | | | +## 화면 단위 현황 -고정 action 328개 안의 수동 전제 6개는 일반 고정 DTO로 임의 생성하지 않는다. - -| 원본 고정 항목 | 수량 | 원본 전용 폼 | 새 대체 경계 | 관계 | -|---|---:|---|---|---| -| 개인·외국인·기관 순매도 상위(수동) | 3 | `Form/FSell.cs` | `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, 신뢰된 s5025 파일 저장소 | 각 audience의 검증된 5행 입력이 있어야 `s5025/5025` 플레이리스트 항목을 만든다. | -| 5단·6종목·12종목 섹션의 VI 발동(수동) placeholder | 3 | `Form/VIList.cs` | `MainWindow.ManualLists.cs`, `Web/manual-lists-workflow.js`, 신뢰된 VI 저장소 | 세 placeholder 모두 원본 VIList가 만든 하나의 `s5074/5074` 5행 페이지 결과로 대체된다. 6·12행 장면으로 바꾸지 않는다. | - -현재 이 수동 6개는 FSell/VIList 대체 화면까지 통합됐다. 고정 카탈로그에서 임의 DTO로 직접 활성화하지 않고, 전용 입력 화면이 저장 후 재조회한 신뢰 데이터와 버전을 공급한 뒤에만 플레이리스트에 넣는다. - -## 화면 단위 완료 요약 - -| 원본 화면 | 핵심 역할 | 화면/계약 구현 상태 | 운영 동등성 DB-W | 운영 동등성 PGM | +| 원본 화면 | 이관된 핵심 동작 | 현재 구현 | 개발 DB/파일 검증 | 개발 PGM/자산 경계 | |---|---|---|---|---| -| MainForm | 종목 검색, 31개 컷, playlist, 송출·배경·저장 | 화면/계약 구현. sibling `배경` root 코드는 적용됐고 실제 외부 배경 자산·package 검증 대기 | 없음/해당 없음 | 일부: 승인된 `5001`, `5074`, TAKE OUT | -| UC1 | 328 fixed, 업종·비교 action 트리 | 412 action과 수동 6개 전용 편집 화면/계약 구현 | 해당 없음 | 일부: generic `5074`; 전체 action 미검증 | -| UC2 | 업종 목록과 두 업종 선택 | 화면/계약 구현 | 해당 없음 | 미검증 | -| UC3 | 비교 대상·저장 비교쌍·9 action | 화면/계약과 strict `종목비교.dat` one-time importer 구현 | importer는 read-only이며 DB-W 없음 | signed package에서 8쌍 1회 실행·재시작 영속성 통과; PGM 미검증 | -| UC4 | 테마 검색·preview·5/6/12행 항목 | 조회/선택 및 ThemeA CRUD 화면/identity 계약 구현 | CRUD 미실행; 실제 KRX/NXT identity 미검증 | 미검증 | -| UC5 | 해외업종·해외종목·해외지수 | 화면/계약 구현 | 해당 없음 | 미검증; `s6001` 국가 영상 13개 외부자산 필요 | -| UC6 | 전문가 조회·추천 preview·5행 항목 | 조회/선택 및 EList/추천 CRUD 화면/identity 계약 구현 | CRUD 미실행; 실제 identity 미검증 | 미검증 | -| UC7 | 거래정지 검색·현재/120일 캔들 | 화면/계약 구현 | 해당 없음 | 미검증 | -| GraphE | 4개 수동 재무 CRUD와 playlist 추가 | 화면/계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 | -| FSell | 개인·외국인·기관 수동 순매도 5행 | 화면/계약, 원본 importer와 production parser 구현 | 운영 데이터 write 미실행 | 현재 3 audience×5행 read-only 검증; 기존 파일 불변 | -| VIList | 수동 VI 목록·순서·5개 단위 page | 화면/계약, 원본 importer와 versioned production parser 구현 | 운영 데이터 write 미실행 | 현재 9항목·2 page read-only 검증; 기존 파일 불변 | -| PList | 이름 있는 playlist 목록·저장·불러오기·삭제 | 화면/계약과 한국어 legacy/KRX/NXT/해외종목/manual restore 구현 | 운영 DB write 미실행 | 2,213행 무손상, 통합 selection 1,603/차단 610; scene/PGM 별도 | -| AList | 프로그램 코드·이름 생성 | PList 화면/계약에 통합 | 운영 DB write 미실행 | 해당 없음 | -| ThemeA | KRX/NXT 테마 구성 CRUD | 화면/identity 계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 | -| EList | 전문가 생성과 추천 구성 CRUD | 화면/identity 계약 구현 / 실환경 검증 대기 | 미실행 | 미검증 | +| MainForm | 종목 검색, 31컷, playlist, 배경, 송출, 단축키, 종료 확인 | LegacyParityApp shell + C# controller | 실제 DB 검색/read smoke PASS | 송출 최종 결과는 검증 문서에 기록 | +| UC1 | 328 fixed, 업종·비교 트리, 부모/leaf batch | C# 폐쇄 inventory와 원자적 section materialize | read-only selector 검증 | 장면별 PGM은 별도 | +| UC2 | 업종 검색, 두 슬롯 회전·교환, 44 action | industry workflow/controller | Oracle read PASS | 장면별 PGM은 별도 | +| UC3 | 비교 대상·검색·쌍 CRUD·9 action | comparison workflow/controller | 원본 8쌍 1회 import·재시작 영속성 검증 | 장면별 PGM은 별도 | +| UC4 | KRX/NXT 테마 검색·preview·5/6/12 선택 | theme workflow; 목록 행 단일 클릭 | KRX/NXT selector PASS | 외부 scene 자산과 PGM 별도 | +| UC5 | 해외업종·해외종목·해외지수 | overseas workflow/controller | Oracle selector PASS | `s6001` 영상 13개 외부자산 미제공 | +| UC6 | 전문가 검색·추천 preview·5행 선택 | expert workflow; 목록 행 단일 클릭 | selector PASS | 장면별 PGM은 별도 | +| UC7 | 거래정지 검색·현재/120일 캔들 | trading-halt workflow; 원본 단일 클릭 | Oracle selector PASS | 장면별 PGM은 별도 | +| GraphE | 4개 재무 CRUD, 정렬·찾기, 행 더블클릭 playlist 추가 | `LegacyManualFinancialWorkflow` | 개발 DB 4개 테이블 write PASS | 전용 scene PGM 별도 | +| FSell | 개인·외국인·기관 각 5행, 확인 시 저장+추가+닫기 | `LegacyManualListsWorkflow` + trusted store | 원본 형식 read와 격리 write 검증 | `s5025` trusted 파일 전제 | +| VIList | 종목 추가·삭제·이동, 5개 단위 page, 확인 시 저장+추가+닫기 | `LegacyManualListsWorkflow` + trusted store | 원본 형식 read와 격리 write 검증 | `s5074` 전용 입력 PGM 별도 | +| PList | 목록, 행 더블클릭 load/save, 확인, 삭제 | `LegacyNamedPlaylistWorkflow` | 개발 DB create/save/read/delete/absence PASS | 복원 항목별 scene PGM 별도 | +| AList | 다음 코드와 프로그램명 신규 생성 | named playlist workflow에 통합 | 위 named playlist 시나리오로 PASS | 해당 없음 | +| ThemeA | KRX/NXT 테마 생성·편집·삭제, 종목 구성·순서 | `LegacyOperatorCatalogWorkflow` | 개발 DB KRX/NXT 각 1개 PASS | scene PGM 별도 | +| EList | 전문가 생성·편집·삭제, 추천 종목·금액·순서 | `LegacyOperatorCatalogWorkflow` | 개발 DB expert 1개 PASS | scene PGM 별도 | -## MainForm 매핑 +UC4/UC6의 결과 행은 고정 action 트리의 click handler에 버블링하지 않는다. UC4 테마, +UC6 전문가와 UC7 거래정지는 원본처럼 **단일 클릭**으로 선택/playlist 동작을 수행하고, +fixed/industry/comparison 트리의 원본 double-click과 독립적으로 처리한다. -원본 파일은 `MainForm.cs`와 `MainForm.Designer.cs`다. +## MainForm 이벤트 매핑 -| 원본 이벤트·컨트롤 | 원본 동작과 입력/저장 경계 | 새 구현 파일·bridge/workflow | 현재 상태 | 자동 테스트 근거 | DB-W / PGM | -|---|---|---|---|---|---| -| `Form1_Load`, `tabControl1_SelectedIndexChanged` | 실행 자산을 읽고 UC1~UC7을 탭에 배치하며 Tornado에 연결한다. | `MainWindow.xaml.cs`, `MainWindow.Playout.cs`, `Web/index.html`, `Web/app.js` | 화면 통합 완료 | `LegacyWebWorkflowIntegrationTests.cs`, `PlayoutBridgeProtocolTests.cs`, Web 전체 회귀 | DB-W 해당 없음 / PGM 일부 완료 | -| `tab_MouseDown`/`tab_MouseMove`/`tab_DragOver`, `swapTabPages` | 원본 10개 업무 탭의 표시 순서를 drag 중 identity 단위로 교환한다. | `Web/market-nav-reorder.js`; dashboard 고정; `data-market` 기반 drag와 Alt+위/아래; 세션 한정 | 코드 적용 / signed package 검증 대기. active view·DB 요청·playlist 상태를 변경하지 않음 | 전용 10/10, 전체 Web 353/353, Debug/Release x64 build | DB-W·PGM 해당 없음 | -| `button9_Click`, `txtSearch_KeyPress`, `searchStock`, `jongmokCode` | 코스피·코스닥·NXT 두 시장씩 검색하고 이름, 코드, 시장을 선택한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/StockSearch.cs`; native `search-stocks`; `Web/operator-workflow.js` | 화면 통합 완료 | `LegacyStockSearchServiceTests.cs`, `operator-workflow.test.cjs` | DB-W 없음 / `5001` 경로 일부 PGM 완료 | -| `listView1_*`, `GridView1_*`, `SetPlayList`, `PlayList_DragDrop` | 실행 자산의 종목 컷을 선택·드래그해 enabled, 시장, 종목, 종류, 세부사항, page, 종목코드 행을 만든다. | `Web/operator-workflow.js`, `Web/app.js`의 종목 workflow와 표준 playlist entry | 화면 통합 완료 | `operator-workflow.test.cjs`, `candle-options-workflow.test.cjs` | DB-W 없음 / 전체 31개 PGM 미검증 | -| `b1_Click` (`주요매출 구성`, `성장성 지표`, `매출액`, `영업이익`) | `GraphE` 네 모드를 연다. | `MainWindow.ManualFinancial.cs`, `Web/manual-financial-workflow.js`, `Web/manual-financial-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | GraphE 표 참조 | DB-W 없음 / PGM 없음 | -| `PlayList_CellClick`, `PlayList_KeyPress`, `checkBox1_CheckedChanged`, `btnup_Click`, `btndown_Click`, `btndelete_Click`, `btntdelete_Click` | 단일·Ctrl·Shift 선택, Space/전체 송출 체크, 순서 이동, 선택·전체 삭제. 송출 중 이동·전체 삭제를 막는다. | `Web/app.js`의 playlist 상태, snapshot lock, `Web/playout-safety.js` | 화면 통합 완료 | `playout-safety.test.cjs`, named/각 workflow의 trusted restore 테스트 | DB-W 없음 / PGM 제어 일부 완료 | -| `button5_Click`, `btnload_Click` | `PList`를 열어 이름 있는 DB 플레이리스트를 저장·불러온다. | `MainWindow.NamedPlaylists.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `Web/named-playlist-workflow.js` | 화면/계약 구현. 기존 한국어 행과 manual 항목의 실DB fresh restore는 적용 중 | named playlist Core/Infrastructure/Web/bridge 테스트 | DB-W 없음 / 복원 항목별 PGM 없음 | -| `btnPrepare_Click`, `btnTake_Click`/F8, `btnNext_Click`, `btnTakeout_Click`/Esc | PREPARE, TAKE IN, 같은 장면 page NEXT 또는 다음 행 NEXT, StopAll 기반 TAKE OUT. | `MainWindow.Playout.cs`; `IPlayoutEngine`; native `playout-command`, `request-playlist-page-plans`; `Web/playout-safety.js` | 화면 통합 완료 | playout/Core/PageN/PGM sequence 테스트와 `playout-safety.test.cjs` | DB-W 해당 없음 / 승인된 `5001`, `5074` page 1·2 및 TAKE OUT만 PGM 확인 | -| `btnback_Click`/F2, `ckback_CheckedChanged`/F3 | 허용 배경 파일 선택, 배경 없음 토글, 송출 중 변경 금지. | `MainWindow.Background.cs`; 별도 `LegacyBackgroundDirectory`; opened-handle/hardlink/reparse 검증; native `request-background-status`, `choose-background`, `toggle-background`; Web 배경 컨트롤 | sibling `배경` root와 `기본.vrv` 코드 적용. 현재 원본 외부 폴더가 없어 fail closed이며 package/PGM 검증 대기 | 별도 root 내부/escape/reparse/hardlink/opened-handle/누락 fixture, Core·Playout Debug/Release 전부 통과, 앱 Debug/Release build | DB-W 해당 없음 / 기능별 PGM 미검증 | -| `timer1_Tick`, `timer2_Tick`, `timer3_Tick`, `MainForm_FormClosing`, `MainForm_Resize`, keyboard handlers | 자동 갱신, 상태 표시, 종료 정리, 단축키를 담당한다. | playout refresh scheduler, cancellation/lifetime hooks, Web 상태·단축키 | 화면 통합 완료 | refresh epoch/scheduler, playout safety, bridge 테스트 | DB-W 해당 없음 / 일부 PGM 완료 | - -실제 PGM 완료는 이미 승인된 제한 회차의 `5001`과 `5074` 흐름만 뜻한다. 412개 전체나 아래 편집 화면의 실제 PGM 동등성을 뜻하지 않는다. - -## UC1~UC7 매핑 - -### UC1 — 해외·환율·지수·업종·비교 action 트리 - -원본: `Control/UC1.cs`, `Control/UC1.Designer.cs` - -| 원본 이벤트·버튼 | 원본 경계 | 새 구현 | 상태·검증 | -|---|---|---|---| -| `UC1_Load`, `INI_parser` | 탭별 실행 자산을 읽어 category/leaf TreeView를 구성한다. | `legacy-fixed-workflow.js`, `industry-workflow.js`, `comparison-workflow.js`의 폐쇄형 inventory | 화면 통합 완료. 각 Web 테스트가 수량·중복·매핑을 고정한다. | -| `chE_CheckedChanged`, `treeView_BeforeSelect`, `treeView_BeforeExpand/BeforeCollapse`, `treeView_DrawNode`, `treeView_MouseDown` | 전체 expand/collapse와 트리 표시를 제어한다. | Web category/section 렌더링과 펼침 상태 | 화면 통합 완료. 시각 상호작용이며 DB-W/PGM 대상 아님. | -| `treeView_DoubleClick` | category 전체 또는 leaf를 playlist에 넣고 5·6·12개 PageN, NXT 제한, 업종 쌍, 비교 쌍을 계산한다. FSell·VI 수동 항목이면 전용 폼으로 분기한다. | 고정 328, 업종 44, 비교 9 workflow의 `create*PlaylistEntry`; page plan bridge; 수동 6개는 `manual-lists-workflow.js`와 `manual-lists-ui.js`로 분리 | 전체 화면 통합 완료. 실제 DB-W 없음, 전체 PGM 미검증. | -| `treeView_AfterSelect`, `btnswap_Click` | 원본에서 실질 동작이 없거나 주석 처리된 이벤트다. | 별도 기능 없음 | 의도적으로 추가 동작을 만들지 않는다. | - -### UC2 — 코스피·코스닥 업종 선택과 쌍 - -원본: `Control/UC2.cs`, `Control/UC2.Designer.cs` - -| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `UC2_Load` | 코스피/코스닥 업종 이름·코드를 DB에서 읽는다. | `src/MBN_STOCK_WEBVIEW.Core/Data/IndustrySelection.cs`; native `request-industries` | 화면 통합 완료. `LegacyIndustrySelectionServiceTests.cs`, `industry-workflow.test.cjs`. | -| `listbox_DoubleClick`, `btnswap_Click` | 선택 업종을 두 슬롯에 회전 입력하고 순서를 바꾼다. | `Web/industry-workflow.js`의 selected/primary/secondary 입력 | 화면 통합 완료. 시장 혼합과 빈 슬롯을 fail closed 한다. | -| UC1 업종 leaf 실행 | 단일, 2열, 수익률 5종, 캔들 12종, 시장 전체 3종으로 시장별 22개를 만든다. | `industry-workflow.js` | 44개 화면 통합 완료. 실제 DB-W 없음 / 업종별 PGM 없음. | - -### UC3 — 비교 대상·저장 비교쌍 - -원본: `Control/UC3.cs`, `Control/UC3.Designer.cs` - -| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `UC3_Load`, `Data_Load` | 24개 고정 비교 대상을 읽고 로컬 `종목비교.dat`의 저장쌍을 불러온다. | `Web/comparison-workflow.js`; 24개 typed target; schema-versioned pair list; `LegacyComparisonPairImport`의 고정 read-only source와 strict CP949 9필드 importer | signed package에서 실제 DB identity를 다시 읽어 8쌍을 한 번 가져왔고, 정상 재시작 뒤 private marker·순서를 확인했다. 원본을 계속 참조하지 않는다. | -| `btnsearch_Click`, `listBox_DoubleClick`, `listBox1_DoubleClick`, `btnsearch2_Click`, `FpSpread2_CellDoubleClick` | 국내 종목, 24개 시장 대상, 해외 종목을 두 슬롯에 회전 입력한다. | native `search-stocks`, `search-world-stocks`; comparison target normalizer | 화면 통합 완료. `comparison-workflow.test.cjs`가 회전·시장·해외 identity를 검증한다. | -| `btnswap_Click`, `btnAdd_Click`, `btn1/2/3/4_Click`, `File_Save` | 쌍 교환, 추가, 선택/전체 삭제, 위/아래 이동 후 저장한다. | comparison pair append/delete/delete-all/reorder와 Web storage; explicit legacy import button | 화면/계약 구현. 기존 CP949 파일은 exact 9필드·fresh DB identity·중복·500쌍 경계를 모두 통과해야 전체가 원자적으로 저장된다. 실제 8쌍 import와 재시작 marker를 확인했다. | -| UC1 비교 leaf 실행 | 현재/예상 2열, 비교 캔들/라인, 5일·1/3/6/12개월 수익률의 9개 action | `comparison-workflow.js` | 9개 화면 통합 완료. 실제 DB-W 없음 / 비교 PGM 없음. | - -### UC4 — 테마 검색·미리보기·송출 선택 - -원본: `Control/UC4.cs`, `Control/UC4.Designer.cs` - -| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `btn1_Click`, `GetThemeByKeyword` | KRX Oracle과 NXT 저장소에서 테마명·코드를 검색한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeSelection.cs`; native `search-themes`; `Web/theme-workflow.js` | 검색 화면 통합 완료. `LegacyThemeSelectionServiceTests.cs`, `theme-workflow.test.cjs`. | -| `FpSpread1_CellClick` | 선택 테마의 구성 종목과 입력 순서를 DB에서 읽고 중지 종목을 제외한다. | native `request-theme-preview`와 typed preview | 화면 통합 완료. 중복 key·시장 불일치·truncated preview를 거부한다. | -| `btnnow_Click`, `btnpre_Click`, `btnnow_6_Click`, `btnnow_12_Click`, double-click | 5단 현재/예상, 6종목 현재, 12종목 현재를 입력순·상승률·하락률로 만들고 5/6/12 page 수를 계산한다. NXT 예상은 차단한다. | `theme-workflow.js`의 compatibility, page preview, playlist entry | 화면 통합 완료. 실제 DB-W 없음 / 테마 PGM 없음. | -| `btn2_Click`, `btn3_Click`, `btn4_Click` | ThemeA 추가·편집 폼을 열거나 선택 테마와 구성 종목을 삭제한다. | `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`, `Web/operator-catalog-ui.js` | ThemeA CRUD 화면 통합 완료. 실제 DB-W/PGM 없음. | - -### UC5 — 해외업종·해외종목·해외지수 - -원본: `Control/UC5.cs`, `Control/UC5.Designer.cs` - -| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `UC5_Load` | 고정 해외지수 목록을 표시한다. | `Web/overseas-workflow.js`의 고정 지수 asset | 화면 통합 완료. | -| `btnsearch1_Click`, `upjong_search` | 미국 해외업종의 입력명·표시명·symbol을 조회한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/OverseasIndustryIndexSearch.cs`; native `search-overseas-industries` | 화면 통합 완료. `LegacyOverseasIndustryIndexSearchServiceTests.cs`. | -| `btnsearch2_Click`, `jongmok_search` | US/TW 해외종목 이름·symbol을 조회한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/WorldStockSearch.cs`; native `search-world-stocks` | 화면 통합 완료. `LegacyWorldStockSearchServiceTests.cs`. | -| `btn1_Click`, `Account_Graph` | 업종/종목 현재 1열과 지수·종목 5/20/60/120/240일 캔들을 playlist에 넣는다. | `Web/overseas-workflow.js` | 화면 통합 완료. `overseas-workflow.test.cjs`; 실제 DB-W 없음 / 해외 PGM 없음. | - -### UC6 — 전문가 목록·추천 종목·송출 선택 - -원본: `Control/UC6.cs`, `Control/UC6.Designer.cs` - -| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `UC6_Load`, `Data_Load`, `FpSpread1_CellClick` | `EXPERT_LIST`를 읽고 선택 전문가의 `RECOMMEND_LIST`를 PLAYINDEX 순서로 미리 본다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertSelection.cs`; native `search-experts`, `request-expert-preview`; `Web/expert-workflow.js` | 검색·선택 화면 통합 완료. `LegacyExpertSelectionServiceTests.cs`, `expert-workflow.test.cjs`. | -| `btlistadd_Click` | 선택 전문가를 5행 page, 최대 20 page의 전문가 추천 항목으로 만든다. | `expert-workflow.js`의 native-normalized preview와 playlist entry | 화면 통합 완료. 실제 DB-W 없음 / 전문가 PGM 없음. | -| `btnAdd_Click` | EList 신규 전문가 폼을 연다. | operator catalog expert create와 `operator-catalog-ui.js` | EList 신규 UI 통합 완료. | -| `btnDelete_Click`, `btndel_Click`, `btnsave_Click`, 종목 검색·double-click | 전문가 삭제, 추천 종목 편집·삭제·재정렬·저장. 원본은 전문가/추천 행을 삭제 후 재삽입한다. | `MainWindow.OperatorCatalogs.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `Web/operator-catalog-workflow.js`, `Web/operator-catalog-ui.js` | EList/전문가 CRUD 화면 통합 완료. 실제 DB-W/PGM 없음. | - -### UC7 — 거래정지 종목 - -원본: `Control/UC7.cs`, `Control/UC7.Designer.cs` - -| 원본 이벤트·버튼 | 원본 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `btnsearch_Click`, `txts_KeyPress`, `search` | 거래정지 코스피와 코스닥 종목을 빈 검색 또는 부분 검색으로 합친다. | `src/MBN_STOCK_WEBVIEW.Core/Data/TradingHaltSelection.cs`; native `search-trading-halts`; `Web/trading-halt-workflow.js` | 화면 통합 완료. `LegacyTradingHaltSelectionServiceTests.cs`. | -| `FpSpread1_CellClick` | 종목명 열 정렬 | Web 정규화 결과의 결정적 정렬 | 화면 통합 완료. | -| `btn4_Click` (`현재가`, `120일 캔들`) | 시장·종목 identity로 현재 1열 또는 거래정지 120일 캔들 항목을 만든다. | `trading-halt-workflow.js` | 화면 통합 완료. `trading-halt-workflow.test.cjs`; 실제 DB-W 없음 / PGM 없음. | - -## 전용 Form 매핑 - -### GraphE — 네 가지 수동 재무 편집기 - -원본: `Form/GraphE.cs`, `Form/GraphE.Designer.cs` - -| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `GraphE_Load`, `Data_Init`, `FpSpread1_CellClick`, `Data_Load` | 네 테이블 전체/검색 목록을 읽고 선택 행을 편집 DTO로 분해한다. | `src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs`; native `request-manual-financial-list`, `request-manual-financial-load`; `Web/manual-financial-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | -| 종목 검색, `listBox_DoubleClick`, `upSearch_Click`, `downSearch_Click` | 정확한 종목을 선택하고 테이블 내 이름을 위/아래로 찾는다. | native stock master 재검증과 manual financial list/search | 화면 통합 완료. 이름만으로 시장·코드를 추정하지 않는다. | -| `btadd_Click` | 기존 이름이면 update, 아니면 insert. `INPUT_PIE`, `INPUT_GROW`, `INPUT_SELL`, `INPUT_PROFIT`의 폐쇄형 필드를 저장한다. | native `create-manual-financial-record`, `update-manual-financial-record`; `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs` | 화면 통합 완료. 단일 트랜잭션, optimistic `rowVersion`, no retry, OutcomeUnknown quarantine 구현. 실제 DB-W 없음. | -| `btndel_Click`, `btnall_Click` | 선택 이름 삭제 또는 테이블 전체 삭제 확인 | native `delete-manual-financial-record`, `delete-all-manual-financial-records` | 화면 통합 완료. 실제 DB-W 없음. | -| `btnAdd_Click`, row double-click, `Select_Item` | DB에 존재하는 행을 정확한 시장/종목과 결합해 1 page playlist 항목으로 추가한다. | native `request-manual-financial-selection`; `Web/manual-financial-workflow.js` | 화면 통합 완료. 저장 복원본은 fresh load + rowVersion/record 일치 + stock 재검증 + native selection 전까지 PREPARE 불가. PGM 없음. | - -화면별 폐쇄형 매핑은 다음과 같다. - -| 원본 모드 | 테이블 | 입력 | builder/code | -|---|---|---|---| -| 주요매출 구성 | `INPUT_PIE` | 종목, 기준일, 최대 5개 구성명·비율 | `s5076/5076` | -| 성장성 지표 | `INPUT_GROW` | 매출·영업이익·순자산·순이익 증가율 각 4개와 분기 4개 | `s5079/5079` | -| 매출액 | `INPUT_SELL` | 분기·금액 6쌍 | `s5080/5080` | -| 영업이익 | `INPUT_PROFIT` | 분기·금액 6쌍 | `s5081/5081` | - -자동 근거는 `LegacyManualFinancialScreenServiceTests.cs`, `OracleManualFinancialMutationExecutorTests.cs`, `manual-financial-workflow.test.cjs`, `manual-financial-bridge-integration.test.cjs`다. - -### FSell — 수동 순매도 3종 - -원본: `Form/FSell.cs`, `Form/FSell.Designer.cs` - -| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `FSell_Load`, `data_load`, `ReadListFile` | 개인·외국인·기관별 로컬 파일에서 5행, 행마다 좌/우 종목·금액을 읽는다. | `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs`의 store/data source; native `request-manual-net-sell-data`; `Web/manual-lists-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | -| `btnok_Click`, `WriteListFile` | 정확히 5행을 저장하고 수동 순매도 항목 생성을 허용한다. | native `save-manual-net-sell-data`; `Web/manual-lists-workflow.js` | 화면 통합 완료. 검증된 versioned 데이터만 `s5025/5025`로 변환한다. 실제 파일 write는 자동 테스트 임시 디렉터리에서만 확인, 운영 데이터 write 없음. | -| `btncn_Click` | 취소 시 placeholder 행을 유지하지 않는다. | workflow가 저장 성공·신뢰 데이터 없이는 항목을 만들지 않는다. | 화면 통합 완료. PGM 없음. | - -자동 근거는 `TrustedManualSceneDataLoader5025Tests.cs`, `S5025TrustedManualFileDataSourceTests.cs`, `S5025TrustedManualFileStoreTests.cs`, `manual-lists-workflow.test.cjs`다. - -### VIList — 수동 VI 목록 - -원본: `Form/VIList.cs`, `Form/VIList.Designer.cs` - -| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `VILIST_Load` | 로컬 VI 목록을 읽는다. | `src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs`; native `request-vi-manual-list`; `Web/manual-lists-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | -| 종목 검색, `listBox_DoubleClick` | 종목 master에서 코스피/코스닥 prefix와 종목명을 추가한다. | native `search-stocks`, `manual-lists-workflow.js`의 strict VI item | 화면 통합 완료. | -| `btn1/2/3/4_Click` | 선택/전체 삭제와 위/아래 이동 | manual lists draft operations | 화면 통합 완료. | -| `btnok_Click` | 목록을 저장하고 종목명을 comma로 결합해 항상 `5단 표그래프 / VI 발동(수동)`으로 추가하며 page를 5개 단위로 계산한다. | native `save-vi-manual-list`; trusted `s5074/5074`, 5개 단위 page entry | 화면 통합 완료. 자동 테스트는 `ViTrustedManualFileStoreTests.cs`, `manual-lists-workflow.test.cjs`, fixed workflow의 VI placeholder 테스트. 운영 write 없음. | -| `btncancel_Click` | 저장·추가 없이 닫는다. | workflow 취소 시 mutation 없음 | 화면 통합 완료. 수동 VI dataset의 PGM 없음. 기존 generic `5074` PGM 검증을 VI 입력 검증으로 간주하지 않는다. | - -### PList + AList — 이름 있는 DB 플레이리스트 - -원본: `Form/PList.cs`, `Form/AList.cs`와 각 Designer - -| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `PList_Load`, `Get_FileName_Folder` | `DC_LIST`의 프로그램 제목·코드를 조회한다. | native `request-named-playlist-list`; `named-playlist-workflow.js` | 화면 통합 완료 | -| `AList_Load`, `GetNextCode`, `btnok_Click` | 다음 8자리 코드와 프로그램명을 생성해 `DC_LIST`에 추가한다. | native `create-named-playlist` | 화면 통합 완료. parameterized transaction 사용. | -| `PList.data_save`, double-click/확인 | 기존 `PLAY_LIST`를 지운 뒤 enabled와 6개 표시 필드를 `LIST_TEXT` 및 ITEM_INDEX 순서로 저장한다. | native `replace-named-playlist`; trusted entry serialization | 화면/계약 구현. raw·untrusted entry는 저장 또는 PREPARE 불가. GraphE·FSell·VI 등 manual 항목의 실제 save→reload→fresh identity/version 보존은 미검증. | -| `PList.data_load`, double-click/확인 | 프로그램 코드별 `PLAY_LIST`를 ITEM_INDEX 순으로 읽어 playlist를 교체한다. | native `request-named-playlist-load`; resolver 기반 materialization | 화면/계약 구현. 실제 2,213행의 한국어 `DC_TITLE`/7필드 `LIST_TEXT` round-trip과 typed persistence는 전부 일치했다. KRX·해외종목·GraphE를 포함한 통합 read-only 증거는 `selectionMapped=1,603`, 차단 610이며 raw DB row는 scene data/asset/page를 재검증할 때까지 PREPARE 불가다. | -| `PList.button1_Click` | 프로그램 정의 삭제 | native `delete-named-playlist` | 화면 통합 완료. 실제 운영 DB-W 없음. | -| 취소 | 아무 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. | - -구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs`, KRX/NXT/해외종목/manual 전용 restore, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs`, `MainWindow.NamedPlaylists.cs`, `Web/named-playlist-workflow.js`에 있다. 자동 근거는 named playlist Core/Infrastructure/Web/bridge 테스트이며 실제 기존 데이터의 read-only 대조는 `docs/NAMED_PLAYLIST_READ_AUDIT.md`에 있다. 실제 운영 DB write와 named playlist 전용 scene/asset/PREPARE/PGM 검증은 아직 없다. - -### ThemeA — 테마 추가·편집 - -원본: `Form/ThemeA.cs`, `Form/ThemeA.Designer.cs` - -| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `Theme_Load` | KRX/NXT 선택, 기존 테마 구성 종목과 순서를 로드한다. | native `request-theme-catalog-list`, `request-operator-catalog-schema`; `operator-catalog-workflow.js`; `operator-catalog-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | -| `btnC_Click` | 시장별 테마명 중복을 확인한다. | native list/identity validation | 화면 통합 완료 | -| 종목 검색·double-click | 같은 시장의 종목만 prefix가 포함된 구성 행으로 추가한다. | stock search + typed theme draft/preview | 화면 통합 완료 | -| `btn1/2/3/4_Click`, Delete key | 선택/전체 삭제, 위/아래 이동 | operator catalog theme draft operations | 화면 통합 완료 | -| `btnok_Click`, `GetNextCode` | 신규 코드를 만들거나 기존 `SB_LIST`/`SB_ITEM`을 교체하고 입력 순서를 저장한다. KRX와 NXT 저장소를 구분한다. | native `request-theme-catalog-next-code`, `create-theme-catalog`, `replace-theme-catalog`; atomic mutation executor | 화면 통합 완료. 실제 운영 DB-W/PGM 없음. | -| UC4 테마 삭제 | `SB_ITEM` 후 `SB_LIST` 삭제 | native `delete-theme-catalog` | 화면 통합 완료. | - -구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs`, `src/MBN_STOCK_WEBVIEW.Core/Data/OperatorCatalogSchemaValidation.cs`, `src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs`, `MainWindow.OperatorCatalogs.cs`, `Web/operator-catalog-workflow.js`다. 자동 근거는 theme catalog, schema, executor, Web workflow 테스트다. expected identity와 응답 correlation 계약은 구현했지만 실제 운영 DB에서 KRX/NXT, 동명이름, 코드가 같은 선택 행을 정확히 replace/delete하는지는 검증 대기다. - -### EList — 전문가 신규 생성 - -원본: `Form/EList.cs`, `Form/EList.Designer.cs` - -| 원본 이벤트·버튼 | 원본 입력·저장 경계 | 새 구현·bridge | 상태·검증 | -|---|---|---|---| -| `EList_Load`, `GetNextCode` | `EXPERT_LIST`의 다음 4자리 코드를 제안한다. | native `request-expert-catalog-next-code`; `operator-catalog-ui.js` | 화면 통합 완료 / 실환경 검증 대기 | -| `btnok_Click` | 이름을 검증하고 `EXPERT_LIST`에 신규 전문가를 저장한다. | native `create-expert-catalog` | 화면 통합 완료. 실제 운영 DB-W 없음. | -| UC6 저장·삭제 | 전문가와 `RECOMMEND_LIST` 구성 종목·금액·PLAYINDEX를 교체 또는 삭제한다. | native `replace-expert-catalog`, `delete-expert-catalog` | 화면 통합 완료. 실제 PGM 없음. | -| `btncn_Click` | 변경 없이 닫는다. | mutation 요청 없음 | 화면 통합 완료. | - -구현은 `src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs`, 공통 schema/executor/native partial, `Web/operator-catalog-workflow.js`다. 자동 근거는 expert catalog, schema, executor, Web workflow 테스트다. 전문가 코드·이름과 추천 행 identity 계약은 구현했지만 실제 운영 DB의 create/replace/delete read-back은 검증 대기다. - -## bridge와 화면 로딩 상태 요약 - -| 기능군 | native request | workflow | 현재 `index.html`/`app.js` | -|---|---|---|---| -| 종목/고정/업종/비교/테마 조회/해외/전문가 조회/거래정지 | `search-stocks`, `request-industries`, `search-themes`, `request-theme-preview`, `search-world-stocks`, `search-overseas-industries`, `search-experts`, `request-expert-preview`, `search-trading-halts` | 각 전용 workflow | 로드·연결됨 | -| 이름 있는 플레이리스트 | `request-named-playlist-list`, `request-named-playlist-load`, `create-named-playlist`, `replace-named-playlist`, `delete-named-playlist` | `named-playlist-workflow.js` | 로드·연결됨 | -| GraphE | `request-manual-financial-list`, `request-manual-financial-load`, `request-manual-financial-selection`, `create-manual-financial-record`, `update-manual-financial-record`, `delete-manual-financial-record`, `delete-all-manual-financial-records` | `manual-financial-workflow.js`, `manual-financial-ui.js` | 로드·연결됨 | -| FSell/VI | `request-manual-operator-data-status`, `request-manual-net-sell-data`, `save-manual-net-sell-data`, `request-vi-manual-list`, `save-vi-manual-list` | `manual-lists-workflow.js`, `manual-lists-ui.js` | 로드·연결됨 | -| ThemeA/EList CRUD | `request-theme-catalog-list`, `request-expert-catalog-list`, `request-operator-catalog-schema`, theme/expert next-code, create/replace/delete requests | `operator-catalog-workflow.js`, `operator-catalog-ui.js` | 로드·연결됨 | - -## 근거 파일 빠른 링크 - -| 영역 | 구현 | 자동 테스트 | +| 원본 이벤트 | 원본 의미 | LegacyParityApp 동작 | |---|---|---| -| 공통 화면·송출·배경 | [MainWindow](../MainWindow.xaml.cs), [Playout partial](../MainWindow.Playout.cs), [Background partial](../MainWindow.Background.cs), [Web app](../Web/app.js) | [playout safety](../tests/Web/playout-safety.test.cjs) | -| 328 fixed / 44 industry / 31 stock / 9 comparison | [fixed workflow](../Web/legacy-fixed-workflow.js), [industry workflow](../Web/industry-workflow.js), [stock workflow](../Web/operator-workflow.js), [comparison workflow](../Web/comparison-workflow.js) | [fixed](../tests/Web/legacy-fixed-workflow.test.cjs), [industry](../tests/Web/industry-workflow.test.cjs), [stock](../tests/Web/operator-workflow.test.cjs), [comparison](../tests/Web/comparison-workflow.test.cjs) | -| 테마 조회·해외·전문가 조회·거래정지 | [theme](../Web/theme-workflow.js), [overseas](../Web/overseas-workflow.js), [expert](../Web/expert-workflow.js), [trading halt](../Web/trading-halt-workflow.js) | [theme](../tests/Web/theme-workflow.test.cjs), [overseas](../tests/Web/overseas-workflow.test.cjs), [expert](../tests/Web/expert-workflow.test.cjs), [trading halt](../tests/Web/trading-halt-workflow.test.cjs) | -| GraphE | [native partial](../MainWindow.ManualFinancial.cs), [Core service](../src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs), [Oracle executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleManualFinancialMutationExecutor.cs), [workflow](../Web/manual-financial-workflow.js) | [Core](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyManualFinancialScreenServiceTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleManualFinancialMutationExecutorTests.cs), [Web](../tests/Web/manual-financial-workflow.test.cjs), [bridge](../tests/Web/manual-financial-bridge-integration.test.cjs) | -| FSell / VI | [native partial](../MainWindow.ManualLists.cs), [FSell store/data source](../src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/S5025TrustedManualFileDataSource.cs), [VI store](../src/MBN_STOCK_WEBVIEW.Infrastructure/Playout/ViTrustedManualFileStore.cs), [workflow](../Web/manual-lists-workflow.js) | [FSell store](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/S5025TrustedManualFileStoreTests.cs), [VI store](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/ViTrustedManualFileStoreTests.cs), [Web](../tests/Web/manual-lists-workflow.test.cjs) | -| PList / AList | [native partial](../MainWindow.NamedPlaylists.cs), [Core service](../src/MBN_STOCK_WEBVIEW.Core/Data/NamedPlaylistPersistence.cs), [Oracle executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OracleNamedPlaylistMutationExecutor.cs), [workflow](../Web/named-playlist-workflow.js) | [Core integration](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyNamedPlaylistPersistenceIntegrationTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OracleNamedPlaylistMutationExecutorTests.cs), [Web](../tests/Web/named-playlist-workflow.test.cjs), [bridge](../tests/Web/named-playlist-bridge-integration.test.cjs) | -| ThemeA / EList | [native partial](../MainWindow.OperatorCatalogs.cs), [theme persistence](../src/MBN_STOCK_WEBVIEW.Core/Data/ThemeCatalogPersistence.cs), [expert persistence](../src/MBN_STOCK_WEBVIEW.Core/Data/ExpertCatalogPersistence.cs), [executor](../src/MBN_STOCK_WEBVIEW.Infrastructure/Execution/OperatorCatalogMutationExecutor.cs), [workflow](../Web/operator-catalog-workflow.js) | [theme](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyThemeCatalogPersistenceServiceTests.cs), [expert](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyExpertCatalogPersistenceServiceTests.cs), [Infrastructure](../tests/MBN_STOCK_WEBVIEW.Infrastructure.Tests/OperatorCatalogMutationExecutorTests.cs), [Web](../tests/Web/operator-catalog-workflow.test.cjs) | +| `Form1_Load` | 자산·DB·탭·Tornado 초기화 | WebView local UI와 C# composition root 초기화; 기본 `DryRun` | +| `tabControl1_SelectedIndexChanged` | 탭별 최초 데이터 조회 | 10개 identity별 C# activation gate; 최초 진입 1회 load, 재진입 snapshot 유지 | +| 탭 drag 이벤트 | 10개 탭 표시 순서 교환 | identity 기반 drag/키보드 이동; active tab·playlist 불변 | +| 검색 버튼/Enter | 종목 검색 | 원본 KOSPI→KOSDAQ→NXT 순서와 마지막 이름 일치 규칙 유지 | +| 컷 click/double-click/drag | 원본 31컷 선택·추가 | plain/Ctrl/Shift/Ctrl+Shift와 원본 double-click 판정, batch 추가 | +| playlist click/key/buttons | 선택, enabled, 이동, 삭제 | C# playlist authority; PREPARED/PROGRAM/pending/unknown에서 mutation 차단 | +| F2/F3 | 배경 선택·없음 | trusted sibling background root만 허용; 누락·reparse·escape fail closed | +| F8 | TAKE IN | idle이면 내부 PREPARE → TAKE IN을 한 번에 실행 | +| NEXT | Page NEXT 또는 다음 행 | 같은 scene PageN 여부를 C#이 판정 | +| Escape | TAKE OUT | modal이 없을 때만 StopAll 기반 TAKE OUT intent | +| FormClosing | 종료 확인 | 기본 취소 확인창; 자동 TAKE OUT/반대 명령 없음 | -## 실제 검증 판정 +## FarPoint 대체와 클릭 규칙 -| 항목 | 결과 | -|---|---| -| 자동 테스트 | Core 1,383개, Infrastructure 173개, Playout 393개가 Debug/Release x64에서 각각 통과했고 Web workflow·bridge 415/415도 통과했다. 412 action의 폐쇄형 매트릭스, 계약·경계·복원 실패와 native fail-closed gate를 검증한다. | -| 실제 운영 DB write | **미실행.** GraphE, named playlist, ThemeA, EList의 mutation executor는 fake/계약 테스트로 검증했으며 운영 DB에는 쓰지 않았다. FSell/VI는 테스트용 격리 저장소만 사용했다. | -| 실제 Tornado2 PGM | 제한된 승인 회차에서 `5001`, `5074` page 1·2, TAKE OUT을 확인했다. 이는 전체 412 action, GraphE, FSell, VI, ThemeA, EList, 이름 있는 playlist 각각의 UI 동등성 검증이 아니다. | -| PList legacy 복원 | 실제 `DC_TITLE`/`LIST_TEXT` 2,213행의 한국어·7필드 무손상과 typed persistence 일치를 확인했다. 기본 `selectionMapped=1,581`/차단 632와 별도 GraphE 22/14를 합친 결과는 1,603/610이다. selection 매핑은 scene data·asset·PREPARE·PGM 완료 증거가 아니다. | -| manual named save | 현재 legacy PList manual 36행은 모두 GraphE이며 fresh proof로 22행을 복원하고 저장값 무효 9행·identity 중복 5행을 차단했다. FSell/VI PList행은 0건이고 production source read는 FSell 3×5행, VI 9항목·2 page로 전후 불변이다. 실제 Oracle save→reload와 PGM은 미검증이다. | -| ThemeA/EList identity | expected identity·상관 응답 계약은 구현했다. 실제 운영 DB의 KRX/NXT·동명이름·코드 경계와 정확한 선택 행 replace/delete는 미검증이다. | -| legacy file import | `종목비교.dat`는 signed package에서 8쌍 1회 실행·재시작 marker까지 확인했다. FSell·VI importer는 strict parser, source/hash 재검증, durable intent/marker와 격리 fixture를 통과했으며 현재 운영 profile은 기존 동일 파일 때문에 no-overwrite 차단된다. | -| 남은 실환경 검증 | 다섯 전용 화면의 Web 연결과 signed `1.0.5.0` Release MSIX package-context 실행은 화면/계약 검증 범위에서 확인됐다. 1.0.5 package UI에서 실제 삼성전자 검색·현재가 5001 DryRun과 자동 refresh cap을 확인했으며, 기존 10개 업무 탭 상태·비교 8쌍 영속성 증거는 1.0.4 감사에 보존한다. 실제 입력→운영 DB write→재조회와 각 전용 scene PGM 검증은 별도 승인·테스트 데이터·rollback 회차에서만 수행한다. | +FarPoint UI는 Web table/list로 표현한다. 다음 의미는 원본과 같이 유지한다. + +- plain/Ctrl/Shift/Ctrl+Shift 선택과 현재 행 +- column header 정렬과 현재 표시 순서 +- 위/아래 이동, 선택/전체 삭제와 enabled 토글 +- single-click과 double-click을 컨트롤별로 구분 +- 부모 section 전체 추가는 모든 자식을 먼저 materialize한 뒤 한 번에 commit +- 수동/지원 불가 child가 있으면 부분 추가 없이 전체 차단 +- modal이 열리면 F2/F3/F8/Escape, Delete/Space/Home/End 등 전역 동작 격리 +- stale generation, 예상하지 않은 row id, 추가 payload field를 strict bridge에서 거부 + +GraphE 행 double-click은 fresh DB record를 다시 확인한 뒤 한 행을 추가하고 닫는다. +Theme 결과 double-click은 원본의 5행 현재가 경로를 사용한다. PList 행 double-click은 +현재 save/load 모드에 맞춰 동작하며, 이름만으로 DB identity를 추정하지 않는다. + +## 원클릭 TAKE IN과 숨김 PREPARE + +원본 운영 화면의 핵심 동작은 별도 PREPARE를 먼저 누르는 것이 아니라 F8/TAKE IN 한 번으로 +송출하는 것이다. 현재 보이는 TAKE IN은 idle에서도 활성 조건을 만족하면 다음을 실행한다. + +1. playlist selection과 page를 C# snapshot으로 고정 +2. DB/신뢰 파일 DTO와 PageN을 fresh load +3. 장면 mutation과 transaction 순서를 적용해 `Prepare` 정확히 1회 +4. 성공한 prepared scene에 `TakeIn` 정확히 1회 + +엔지니어링용 native PREPARE control과 explicit workflow는 테스트를 위해 유지하지만 Web UI에서 +`hidden`이다. TAKE IN 실패·timeout·`OutcomeUnknown`이면 자동 재시도하지 않으며 성공한 +prepared ownership을 추측해 지우지 않는다. + +## PageN과 NEXT + +| scene | page size | 규칙 | +|---|---:|---| +| `s5074` | 5 | 테마/전문가/VI 등 5행 경로 | +| `s5077` | 6 | 6종목 테마 경로 | +| `s5088` | 12 | 12종목 테마 경로 | + +`pageCount = min(20, ceil(itemCount / pageSize))`를 사용한다. 0행, 정확한 경계, +마지막 부분 페이지, 20페이지 cap, 21페이지 요청 거부, 같은 scene의 `GetPlayingScene` +Page NEXT와 다음 enabled playlist 항목 NEXT를 자동 테스트로 비교한다. + +## 개발 DB write 증적 + +실제 개발 Oracle/MariaDB에서 production executor를 사용한 9개 충돌 격리 시나리오가 +create/save → fresh readback → delete → absence를 통과했다. + +| 기능 | 시나리오 | +|---|---:| +| GraphE `INPUT_PIE/GROW/SELL/PROFIT` | 4 | +| PList/AList named playlist | 2 | +| ThemeA KRX/NXT | 2 | +| EList expert | 1 | +| 합계 | **9** | + +이는 개발 DB 통합 증거이며 운영 DB write를 수행했다는 뜻이 아니다. write 재시도는 없고, +결과가 불명확하면 cleanup과 다음 시나리오도 실행하지 않는 fail-closed 규칙을 유지한다. + +## read-only·scene·asset 증적 + +- 실제 Oracle/MariaDB 필수 scene selector **33/33**, 전체 query 58건 PASS +- 원본 scene builder 자동 기준선 **35/35** +- MainForm 도달 runtime 34개 builder와 active alias **45개** +- 승인된 Cuts에 45 alias 대응 `.t2s` 존재 +- `s5006` `Video\큐브배경.vrv` 1개와 `s6001` 국가 영상 13개는 외부자산 미제공 +- sibling `배경\기본.vrv`도 외부자산 미제공 + +마지막 14개 영상과 `기본.vrv`의 부재는 UI/scene 코드 실패가 아니다. 해당 기능은 자산이 +제공될 때까지 COM 전에 차단하며 임의 파일로 대체하지 않는다. + +## 구현·테스트 빠른 링크 + +| 영역 | 현재 실행 구현 | 주요 테스트/근거 | +|---|---|---| +| WinUI 3 + WebView2 shell | [MainWindow.xaml.cs](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs), [Web app](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js), [Web layout](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html) | [thin client](../tests/LegacyParityWeb/thin-client.test.cjs) | +| C# 업무 권위 | [LegacyOperatorController.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs) | [LegacyApplication tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests) | +| strict bridge | [LegacyBridgeProtocol.cs](../src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs) | [LegacyBridge tests](../tests/MBN_STOCK_WEBVIEW.LegacyBridge.Tests) | +| 송출 | [MainWindow.Playout.cs](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs), [playout-ui.js](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/playout-ui.js), [LegacyPlayoutWorkflow.cs](../src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs) | [LegacyPlayoutWorkflowTests.cs](../tests/MBN_STOCK_WEBVIEW.Core.Tests/LegacyPlayoutWorkflowTests.cs) | +| 수동 목록 | [MainWindow.ManualLists.cs](../src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.ManualLists.cs), [LegacyManualListsWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs) | [manual-list tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualLists) | +| GraphE | [LegacyManualFinancialWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs) | [manual-financial tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/ManualFinancial) | +| PList/AList | [LegacyNamedPlaylistWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs) | [named-playlist tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/NamedPlaylist) | +| ThemeA/EList | [LegacyOperatorCatalogWorkflow.cs](../src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs) | [catalog tests](../tests/MBN_STOCK_WEBVIEW.LegacyApplication.Tests/OperatorCatalog) | +| scene matrix | [SCENE_EQUIVALENCE.md](SCENE_EQUIVALENCE.md) | 35/35 builder, 45 alias, PageN matrix | +| 2026-07-15 증적 | [LEGACY_PARITY_VALIDATION_20260715.md](LEGACY_PARITY_VALIDATION_20260715.md) | 최종 전체 회귀 기록란 포함 | + +최종 Debug/Release 전체 테스트 개수, 새 MSIX SHA-256와 개발 PGM 결과는 병렬 변경을 +모두 합친 뒤 위 검증 문서에 기록했다. 이전 package 숫자나 hash를 현재 결과로 +재사용하지 않았다. diff --git a/scripts/Export-LegacyFixedCatalog.cjs b/scripts/Export-LegacyFixedCatalog.cjs new file mode 100644 index 0000000..86d4f2a --- /dev/null +++ b/scripts/Export-LegacyFixedCatalog.cjs @@ -0,0 +1,56 @@ +"use strict"; + +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const path = require("node:path"); + +const repositoryRoot = path.resolve(__dirname, ".."); +const sourcePath = path.join(repositoryRoot, "Web", "legacy-fixed-workflow.js"); +const outputPath = path.join( + repositoryRoot, + "src", + "MBN_STOCK_WEBVIEW.LegacyApplication", + "Catalog", + "LegacyFixedActions.json"); + +const sourceBytes = fs.readFileSync(sourcePath); +const workflow = require(sourcePath); +if (!Array.isArray(workflow.actions) || workflow.actions.length !== 328) { + throw new Error("The fixed legacy catalog must contain exactly 328 actions."); +} + +const actions = workflow.actions.map((action, index) => { + const expectedId = `fixed-${String(index + 1).padStart(3, "0")}`; + if (action.id !== expectedId || !workflow.fixedMarkets.includes(action.market)) { + throw new Error(`Unexpected fixed action identity at index ${index}.`); + } + + return { + id: action.id, + market: action.market, + section: action.section, + label: action.label, + detail: action.detail, + category: action.category, + builderKey: action.builderKey, + code: action.code, + aliases: action.aliases, + available: action.available, + prerequisite: action.prerequisite, + selection: action.selection, + dynamicDate: action.dynamicDate, + dynamicNxtSession: action.dynamicNxtSession + }; +}); + +const document = { + schemaVersion: 1, + generatedFrom: "Web/legacy-fixed-workflow.js", + sourceSha256: crypto.createHash("sha256").update(sourceBytes).digest("hex").toUpperCase(), + runtimeAssets: workflow.runtimeAssets, + actions +}; + +fs.mkdirSync(path.dirname(outputPath), { recursive: true }); +fs.writeFileSync(outputPath, `${JSON.stringify(document, null, 2)}\n`, "utf8"); +process.stdout.write(`${outputPath}\n`); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs index 7b4f4b8..b67729a 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Data/ManualFinancialScreens.cs @@ -1054,13 +1054,14 @@ internal static class ManualFinancialValidation new ManualRevenueCompositionRecord( identity, Text( - RequiredString(row, "BASE_DATE"), + OptionalOracleString(row, "BASE_DATE"), MaximumTextLength, allowEmpty: true, allowUnderscore: true, nameof(row)), Enumerable.Range(1, 5) - .Select(index => ParseSlice(RequiredString(row, "GUSUNG_" + index))) + .Select(index => ParseSlice( + OptionalOracleString(row, "GUSUNG_" + index))) .ToArray()), ManualFinancialScreenKind.GrowthMetrics => ParseGrowth(identity, row), ManualFinancialScreenKind.Sales => @@ -1324,6 +1325,23 @@ internal static class ManualFinancialValidation return value; } + private static string OptionalOracleString(DataRow row, string columnName) + { + if (!row.Table.Columns.Contains(columnName)) + { + throw new ArgumentException("A manual-financial database value is invalid.", nameof(row)); + } + + return row[columnName] switch + { + string value => value, + DBNull => string.Empty, + _ => throw new ArgumentException( + "A manual-financial database value is invalid.", + nameof(row)) + }; + } + private static string RowVersion(string value, string parameterName) { ArgumentNullException.ThrowIfNull(value, parameterName); diff --git a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs index 54dbf5c..bcf5a09 100644 --- a/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs +++ b/src/MBN_STOCK_WEBVIEW.Core/Playout/Scenes/LegacyPlayoutWorkflow.cs @@ -293,6 +293,72 @@ public sealed class LegacyPlayoutWorkflow } } + /// + /// Reproduces the visible MainForm TAKE IN/F8 path from idle: ONAirMode builds and + /// prepares the selected scene, then Play starts that exact prepared instance. The + /// page DTO is therefore loaded once and the workflow gate covers both engine calls. + /// This is intentionally separate from , which preserves the + /// legacy explicit-PREPARE path and refreshes its page before play. + /// + public async Task PrepareAndTakeInAsync( + IReadOnlyList playlist, + int cueIndexZeroBased, + CancellationToken cancellationToken = default) + { + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_prepared is not null || _onAir is not null) + { + return Rejected( + PlayoutOperation.TakeIn, + "One-click TAKE IN can start only while the playout workflow is idle."); + } + + var snapshot = ValidatePlaylist(playlist, cueIndexZeroBased); + var selectedIndex = FindNextEnabledCue(snapshot, cueIndexZeroBased); + if (selectedIndex is null) + { + return Rejected( + PlayoutOperation.Prepare, + "There is no enabled playlist entry at or after the selection."); + } + + var page = await CreatePageAsync( + snapshot, + selectedIndex.Value, + pageIndexZeroBased: 0, + cancellationToken).ConfigureAwait(false); + var prepare = await _engine.PrepareAsync(page.Page.Cue, cancellationToken) + .ConfigureAwait(false); + if (!prepare.IsSuccess) + { + return prepare; + } + + // Once PREPARE succeeds the engine owns this scene. Keep that prepared + // state if TAKE IN fails or its outcome is unknown; never reload or retry. + _playlist = snapshot; + _prepared = page; + PublishState(); + + var takeIn = await _engine.TakeInAsync(cancellationToken).ConfigureAwait(false); + if (takeIn.IsSuccess) + { + _onAir = page; + _prepared = null; + PublishState(); + } + + return takeIn; + } + finally + { + ApplyRequestedEngineClear(); + _gate.Release(); + } + } + public async Task TakeInAsync( CancellationToken cancellationToken = default) { diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Catalog/LegacyFixedActions.json b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Catalog/LegacyFixedActions.json new file mode 100644 index 0000000..d2fe453 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Catalog/LegacyFixedActions.json @@ -0,0 +1,7893 @@ +{ + "schemaVersion": 1, + "generatedFrom": "Web/legacy-fixed-workflow.js", + "sourceSha256": "31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5", + "runtimeAssets": { + "overseas": { + "relativePath": "bin/Debug/Res/해외.ini", + "sha256": "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A" + }, + "exchange": { + "relativePath": "bin/Debug/Res/환율.ini", + "sha256": "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C" + }, + "index": { + "relativePath": "bin/Debug/Res/지수.ini", + "sha256": "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6" + } + }, + "actions": [ + { + "id": "fixed-001", + "market": "overseas", + "section": "1열판기본", + "label": "다우", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "Dow", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-002", + "market": "overseas", + "section": "1열판기본", + "label": "나스닥", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "Nasdaq", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-003", + "market": "overseas", + "section": "1열판기본", + "label": "S&P", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "Sp500", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-004", + "market": "overseas", + "section": "1열판기본", + "label": "독일", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "GermanyDax", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-005", + "market": "overseas", + "section": "1열판기본", + "label": "영국", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "UnitedKingdomFtse", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-006", + "market": "overseas", + "section": "1열판기본", + "label": "프랑스", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "FranceCac", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-007", + "market": "overseas", + "section": "1열판기본", + "label": "니케이", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "Nikkei", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-008", + "market": "overseas", + "section": "1열판기본", + "label": "중국 상해", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "ShanghaiComposite", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-009", + "market": "overseas", + "section": "1열판기본", + "label": "홍콩 항셍", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "HangSeng", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-010", + "market": "overseas", + "section": "1열판기본", + "label": "대만 가권", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "TaiwanWeighted", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-011", + "market": "overseas", + "section": "1열판기본", + "label": "싱가포르 지수", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "SingaporeStraitsTimes", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-012", + "market": "overseas", + "section": "1열판기본", + "label": "태국 지수", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "ThailandSet", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-013", + "market": "overseas", + "section": "1열판기본", + "label": "필리핀 지수", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "PhilippinesComposite", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-014", + "market": "overseas", + "section": "1열판기본", + "label": "말레이시아 지수", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "MalaysiaKlse", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-015", + "market": "overseas", + "section": "1열판기본", + "label": "인도네시아 지수", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "IndonesiaComposite", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-016", + "market": "overseas", + "section": "1열판기본", + "label": "BDI 지수", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "BALTIC_DRY_INDEX", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-017", + "market": "overseas", + "section": "1열판기본", + "label": "소맥", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "FeedWheat", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-018", + "market": "overseas", + "section": "1열판기본", + "label": "옥수수", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Corn", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-019", + "market": "overseas", + "section": "1열판기본", + "label": "밀", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Wheat", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-020", + "market": "overseas", + "section": "1열판기본", + "label": "대두", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Soybean", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-021", + "market": "overseas", + "section": "1열판기본", + "label": "콩", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Bean", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-022", + "market": "overseas", + "section": "1열판기본", + "label": "현미", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "BrownRice", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-023", + "market": "overseas", + "section": "1열판기본", + "label": "커피", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Coffee", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-024", + "market": "overseas", + "section": "1열판기본", + "label": "코코아", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Cocoa", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-025", + "market": "overseas", + "section": "1열판기본", + "label": "설탕", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Sugar", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-026", + "market": "overseas", + "section": "1열판기본", + "label": "생우(소)", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "LiveCattle", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-027", + "market": "overseas", + "section": "1열판기본", + "label": "비육우", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "FeederCattle", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-028", + "market": "overseas", + "section": "1열판기본", + "label": "돈육(돼지)", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "LeanHogs", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-029", + "market": "overseas", + "section": "1열판기본", + "label": "구리", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Copper", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-030", + "market": "overseas", + "section": "1열판기본", + "label": "철광석", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "IronOre", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-031", + "market": "overseas", + "section": "1열판기본", + "label": "니켈", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Nickel", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-032", + "market": "overseas", + "section": "1열판기본", + "label": "천연가스", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "NaturalGas", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-033", + "market": "overseas", + "section": "1열판기본", + "label": "백금", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Platinum", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-034", + "market": "overseas", + "section": "1열판기본", + "label": "팔라듐", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Palladium", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-035", + "market": "overseas", + "section": "1열판기본", + "label": "납", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Lead", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-036", + "market": "overseas", + "section": "1열판기본", + "label": "아연", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Zinc", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-037", + "market": "overseas", + "section": "1열판기본", + "label": "주석", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Tin", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-038", + "market": "overseas", + "section": "1열판기본", + "label": "원면", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "RawCotton", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-039", + "market": "overseas", + "section": "1열판기본", + "label": "목화(면화)", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "Cotton", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-040", + "market": "overseas", + "section": "1열판기본", + "label": "국제 금", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "InternationalGold", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-041", + "market": "overseas", + "section": "1열판기본", + "label": "국제 은", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "InternationalSilver", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-042", + "market": "overseas", + "section": "1열판기본", + "label": "국내 금", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "DomesticGold", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-043", + "market": "overseas", + "section": "1열판기본", + "label": "국내 은", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "COMMODITY", + "subject": "DomesticSilver", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-044", + "market": "overseas", + "section": "2열판-", + "label": "농산물[원면, 목화(면화)]", + "detail": "2열판-", + "category": "plate", + "builderKey": "s50160", + "code": "50160", + "aliases": [ + "50160" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "2열판-", + "subtype": "Cotton", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-045", + "market": "overseas", + "section": "2열판-", + "label": "원자재[국제 금, 국제 은]", + "detail": "2열판-", + "category": "plate", + "builderKey": "s50160", + "code": "50160", + "aliases": [ + "50160" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "2열판-", + "subtype": "InternationalPreciousMetals", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-046", + "market": "overseas", + "section": "2열판-", + "label": "원자재[국내 금, 국내 은]", + "detail": "2열판-", + "category": "plate", + "builderKey": "s50160", + "code": "50160", + "aliases": [ + "50160" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "2열판-", + "subtype": "DomesticPreciousMetals", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-047", + "market": "overseas", + "section": "3열판", + "label": "미국 지수[다우, 나스닥, S&P]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "UnitedStatesIndices", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-048", + "market": "overseas", + "section": "3열판", + "label": "중화권 지수[홍콩항셍, 상해종합, 대만]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "GreaterChinaIndices", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-049", + "market": "overseas", + "section": "3열판", + "label": "유럽권 지수[영국, 프랑스, 독일]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "EuropeanIndices", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-050", + "market": "overseas", + "section": "3열판", + "label": "아시아 지수[상해, 대만, 일본]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "AsianIndices", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-051", + "market": "overseas", + "section": "3열판", + "label": "미국국채[2년, 3년, 5년]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "UnitedStatesTreasuries", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-052", + "market": "overseas", + "section": "3열판", + "label": "채권금리(CD,CP,콜금리)", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "BondYields", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-053", + "market": "overseas", + "section": "3열판", + "label": "유가[두바이, 브렌트유, WTI]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "Oil", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-054", + "market": "overseas", + "section": "3열판", + "label": "광물[금, 은, 구리]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "Minerals", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-055", + "market": "overseas", + "section": "3열판", + "label": "식자재[대두, 옥수수, 밀]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "FoodMaterials", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-056", + "market": "overseas", + "section": "3열판", + "label": "농산물[소맥, 옥수수, 밀]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "AgricultureWheatCorn", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-057", + "market": "overseas", + "section": "3열판", + "label": "농산물[대두, 콩, 현미]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "AgricultureSoyRice", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-058", + "market": "overseas", + "section": "3열판", + "label": "농산물[커피, 코코아, 설탕]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "AgricultureCoffeeCocoaSugar", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-059", + "market": "overseas", + "section": "3열판", + "label": "농산물[생우(소), 비육우, 돈육(돼지)]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "AgricultureLivestock", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-060", + "market": "overseas", + "section": "3열판", + "label": "원자재[구리, 철광석, 니켈]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "RawMaterialsCopperIronNickel", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-061", + "market": "overseas", + "section": "3열판", + "label": "원자재[천연가스, 백금, 팔라듐]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "RawMaterialsGasPlatinumPalladium", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-062", + "market": "overseas", + "section": "3열판", + "label": "원자재[납, 아연, 주석]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "RawMaterialsLeadZincTin", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-063", + "market": "overseas", + "section": "글로벌 증시 지도", + "label": "글로벌 증시 지도", + "detail": "글로벌 증시 지도", + "category": "market", + "builderKey": "s8067", + "code": "8067", + "aliases": [ + "8067" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "WORLD_MAP", + "subtype": "8067", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-064", + "market": "overseas", + "section": "글로벌 증시 지도", + "label": "아시아 증시 지도", + "detail": "글로벌 증시 지도", + "category": "market", + "builderKey": "s8067", + "code": "5072", + "aliases": [ + "5072" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "WORLD_MAP", + "subtype": "5072", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-065", + "market": "overseas", + "section": "글로벌 증시 지도", + "label": "미국 증시 지도", + "detail": "글로벌 증시 지도", + "category": "market", + "builderKey": "s8067", + "code": "5068", + "aliases": [ + "5068" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "WORLD_MAP", + "subtype": "5068", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-066", + "market": "overseas", + "section": "글로벌 증시 지도", + "label": "유럽 증시 지도", + "detail": "글로벌 증시 지도", + "category": "market", + "builderKey": "s8067", + "code": "5070", + "aliases": [ + "5070" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "WORLD_MAP", + "subtype": "5070", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-067", + "market": "overseas", + "section": "이미지 그래프", + "label": "미국 다우 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "미국 다우 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "DowJones", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-068", + "market": "overseas", + "section": "이미지 그래프", + "label": "미국 나스닥 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "미국 나스닥 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "Nasdaq", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-069", + "market": "overseas", + "section": "이미지 그래프", + "label": "미국 S&P 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "미국 S&P 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "StandardAndPoor500", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-070", + "market": "overseas", + "section": "이미지 그래프", + "label": "중국 상해 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "중국 상해 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "China", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-071", + "market": "overseas", + "section": "이미지 그래프", + "label": "일본 닛케이 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "일본 닛케이 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "Japan", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-072", + "market": "overseas", + "section": "이미지 그래프", + "label": "유가 두바이 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "유가 두바이 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "DubaiCrude", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-073", + "market": "overseas", + "section": "이미지 그래프", + "label": "유가 WTI 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "유가 WTI 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "WtiCrude", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-074", + "market": "overseas", + "section": "이미지 그래프", + "label": "유가 브렌트유 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "유가 브렌트유 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "BrentCrude", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-075", + "market": "overseas", + "section": "이미지 그래프", + "label": "금 지수", + "detail": "이미지 그래프", + "category": "market", + "builderKey": "s6001", + "code": "6001", + "aliases": [ + "6001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "금 지수", + "graphicType": "IMAGE_GRAPH", + "subtype": "Gold", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-076", + "market": "overseas", + "section": "미국업종", + "label": "다우", + "detail": "미국업종", + "category": "chart", + "builderKey": "s5078", + "code": "5078", + "aliases": [ + "5078" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "DOW", + "graphicType": "US_SECTOR_INDEX", + "subtype": "", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-077", + "market": "overseas", + "section": "미국업종", + "label": "나스닥", + "detail": "미국업종", + "category": "chart", + "builderKey": "s5078", + "code": "5078", + "aliases": [ + "5078" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "NASDAQ", + "graphicType": "US_SECTOR_INDEX", + "subtype": "", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-078", + "market": "overseas", + "section": "미국업종", + "label": "S&P", + "detail": "미국업종", + "category": "chart", + "builderKey": "s5078", + "code": "5078", + "aliases": [ + "5078" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FOREIGN_INDEX", + "subject": "S&P500", + "graphicType": "US_SECTOR_INDEX", + "subtype": "", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-079", + "market": "exchange", + "section": "1열판기본", + "label": "원달러", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-080", + "market": "exchange", + "section": "1열판기본", + "label": "원엔", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-081", + "market": "exchange", + "section": "1열판기본", + "label": "원위엔", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-082", + "market": "exchange", + "section": "1열판기본", + "label": "원유로", + "detail": "1열판기본", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-083", + "market": "exchange", + "section": "3열판", + "label": "해외환율[엔달러, 위안화달러, 유로달러]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "OverseasExchangeRates", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-084", + "market": "exchange", + "section": "3열판", + "label": "환율[원달러, 원엔, 원유로]", + "detail": "3열판", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "DomesticExchangeRates", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-085", + "market": "exchange", + "section": "수익률 그래프", + "label": "원달러_5일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-086", + "market": "exchange", + "section": "수익률 그래프", + "label": "원달러_20일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-087", + "market": "exchange", + "section": "수익률 그래프", + "label": "원달러_60일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-088", + "market": "exchange", + "section": "수익률 그래프", + "label": "원달러_120일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-089", + "market": "exchange", + "section": "수익률 그래프", + "label": "원달러_240일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-090", + "market": "exchange", + "section": "수익률 그래프", + "label": "원엔_5일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-091", + "market": "exchange", + "section": "수익률 그래프", + "label": "원엔_20일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-092", + "market": "exchange", + "section": "수익률 그래프", + "label": "원엔_60일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-093", + "market": "exchange", + "section": "수익률 그래프", + "label": "원엔_120일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-094", + "market": "exchange", + "section": "수익률 그래프", + "label": "원엔_240일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-095", + "market": "exchange", + "section": "수익률 그래프", + "label": "원위엔_5일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-096", + "market": "exchange", + "section": "수익률 그래프", + "label": "원위엔_20일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-097", + "market": "exchange", + "section": "수익률 그래프", + "label": "원위엔_60일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-098", + "market": "exchange", + "section": "수익률 그래프", + "label": "원위엔_120일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-099", + "market": "exchange", + "section": "수익률 그래프", + "label": "원위엔_240일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-100", + "market": "exchange", + "section": "수익률 그래프", + "label": "원유로_5일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-101", + "market": "exchange", + "section": "수익률 그래프", + "label": "원유로_20일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-102", + "market": "exchange", + "section": "수익률 그래프", + "label": "원유로_60일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-103", + "market": "exchange", + "section": "수익률 그래프", + "label": "원유로_120일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-104", + "market": "exchange", + "section": "수익률 그래프", + "label": "원유로_240일", + "detail": "수익률 그래프", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-105", + "market": "exchange", + "section": "라인 그래프", + "label": "원달러_5일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "LINE_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-106", + "market": "exchange", + "section": "라인 그래프", + "label": "원달러_20일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "LINE_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-107", + "market": "exchange", + "section": "라인 그래프", + "label": "원달러_60일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "LINE_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-108", + "market": "exchange", + "section": "라인 그래프", + "label": "원달러_120일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "LINE_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-109", + "market": "exchange", + "section": "라인 그래프", + "label": "원달러_240일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonDollar", + "graphicType": "LINE_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-110", + "market": "exchange", + "section": "라인 그래프", + "label": "원엔_5일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "LINE_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-111", + "market": "exchange", + "section": "라인 그래프", + "label": "원엔_20일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "LINE_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-112", + "market": "exchange", + "section": "라인 그래프", + "label": "원엔_60일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "LINE_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-113", + "market": "exchange", + "section": "라인 그래프", + "label": "원엔_120일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "LINE_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-114", + "market": "exchange", + "section": "라인 그래프", + "label": "원엔_240일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYen", + "graphicType": "LINE_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-115", + "market": "exchange", + "section": "라인 그래프", + "label": "원위엔_5일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "LINE_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-116", + "market": "exchange", + "section": "라인 그래프", + "label": "원위엔_20일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "LINE_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-117", + "market": "exchange", + "section": "라인 그래프", + "label": "원위엔_60일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "LINE_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-118", + "market": "exchange", + "section": "라인 그래프", + "label": "원위엔_120일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "LINE_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-119", + "market": "exchange", + "section": "라인 그래프", + "label": "원위엔_240일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonYuan", + "graphicType": "LINE_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-120", + "market": "exchange", + "section": "라인 그래프", + "label": "원유로_5일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "LINE_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-121", + "market": "exchange", + "section": "라인 그래프", + "label": "원유로_20일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "LINE_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-122", + "market": "exchange", + "section": "라인 그래프", + "label": "원유로_60일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "LINE_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-123", + "market": "exchange", + "section": "라인 그래프", + "label": "원유로_120일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "LINE_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-124", + "market": "exchange", + "section": "라인 그래프", + "label": "원유로_240일", + "detail": "라인 그래프", + "category": "chart", + "builderKey": "s50860", + "code": "50860", + "aliases": [ + "50860" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "EXCHANGE_RATE", + "subject": "WonEuro", + "graphicType": "LINE_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-125", + "market": "index", + "section": "주요지수", + "label": "3열판_주요지수[코스피, 코스닥, 코스피200]", + "detail": "주요지수", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "MajorKospiKosdaqKospi200", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-126", + "market": "index", + "section": "주요지수", + "label": "3열판_주요지수[코스피, 코스닥, 선물]", + "detail": "주요지수", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "MajorKospiKosdaqFutures", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-127", + "market": "index", + "section": "주요지수", + "label": "3열판_예상체결지수[코스피, 코스닥, 코스피200]", + "detail": "주요지수", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "ExpectedKospiKosdaqKospi200", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-128", + "market": "index", + "section": "주요지수", + "label": "3열판_예상체결지수[코스피, 코스닥, 선물]", + "detail": "주요지수", + "category": "plate", + "builderKey": "s5016", + "code": "5016", + "aliases": [ + "5016" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "3열판", + "subtype": "ExpectedKospiKosdaqFutures", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-129", + "market": "index", + "section": "코스피", + "label": "1열판기본_예상지수", + "detail": "코스피", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "EXPECTED_INDEX", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-130", + "market": "index", + "section": "코스피", + "label": "1열판기본_현재지수", + "detail": "코스피", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-131", + "market": "index", + "section": "코스피", + "label": "수익률그래프_5일", + "detail": "코스피", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-132", + "market": "index", + "section": "코스피", + "label": "수익률그래프_20일", + "detail": "코스피", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-133", + "market": "index", + "section": "코스피", + "label": "수익률그래프_60일", + "detail": "코스피", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-134", + "market": "index", + "section": "코스피", + "label": "수익률그래프_120일", + "detail": "코스피", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-135", + "market": "index", + "section": "코스피", + "label": "수익률그래프_240일", + "detail": "코스피", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-136", + "market": "index", + "section": "코스피", + "label": "캔들그래프_일봉", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-137", + "market": "index", + "section": "코스피", + "label": "캔들그래프_5일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-138", + "market": "index", + "section": "코스피", + "label": "캔들그래프_20일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-139", + "market": "index", + "section": "코스피", + "label": "캔들그래프_60일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-140", + "market": "index", + "section": "코스피", + "label": "캔들그래프_120일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-141", + "market": "index", + "section": "코스피", + "label": "캔들그래프_240일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-142", + "market": "index", + "section": "코스피", + "label": "캔들그래프(거래량)_일봉", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-143", + "market": "index", + "section": "코스피", + "label": "캔들그래프(거래량)_5일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-144", + "market": "index", + "section": "코스피", + "label": "캔들그래프(거래량)_20일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-145", + "market": "index", + "section": "코스피", + "label": "캔들그래프(거래량)_60일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-146", + "market": "index", + "section": "코스피", + "label": "캔들그래프(거래량)_120일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-147", + "market": "index", + "section": "코스피", + "label": "캔들그래프(거래량)_240일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-148", + "market": "index", + "section": "코스피", + "label": "캔들그래프(예상지수)_5일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-149", + "market": "index", + "section": "코스피", + "label": "캔들그래프(예상지수)_20일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-150", + "market": "index", + "section": "코스피", + "label": "캔들그래프(예상지수)_60일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-151", + "market": "index", + "section": "코스피", + "label": "캔들그래프(예상지수)_120일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-152", + "market": "index", + "section": "코스피", + "label": "캔들그래프(예상지수)_240일", + "detail": "코스피", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-153", + "market": "index", + "section": "코스닥", + "label": "1열판기본_예상지수", + "detail": "코스닥", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "EXPECTED_INDEX", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-154", + "market": "index", + "section": "코스닥", + "label": "1열판기본_현재지수", + "detail": "코스닥", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-155", + "market": "index", + "section": "코스닥", + "label": "수익률그래프_5일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-156", + "market": "index", + "section": "코스닥", + "label": "수익률그래프_20일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-157", + "market": "index", + "section": "코스닥", + "label": "수익률그래프_60일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-158", + "market": "index", + "section": "코스닥", + "label": "수익률그래프_120일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-159", + "market": "index", + "section": "코스닥", + "label": "수익률그래프_240일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-160", + "market": "index", + "section": "코스닥", + "label": "캔들그래프_일봉", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-161", + "market": "index", + "section": "코스닥", + "label": "캔들그래프_5일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-162", + "market": "index", + "section": "코스닥", + "label": "캔들그래프_20일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-163", + "market": "index", + "section": "코스닥", + "label": "캔들그래프_60일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-164", + "market": "index", + "section": "코스닥", + "label": "캔들그래프_120일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-165", + "market": "index", + "section": "코스닥", + "label": "캔들그래프_240일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-166", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(거래량)_일봉", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-167", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(거래량)_5일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-168", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(거래량)_20일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-169", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(거래량)_60일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-170", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(거래량)_120일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-171", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(거래량)_240일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-172", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(예상지수)_5일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-173", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(예상지수)_20일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-174", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(예상지수)_60일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-175", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(예상지수)_120일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-176", + "market": "index", + "section": "코스닥", + "label": "캔들그래프(예상지수)_240일", + "detail": "코스닥", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-177", + "market": "index", + "section": "코스피200", + "label": "1열판기본_예상지수", + "detail": "코스피200", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "EXPECTED_INDEX", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-178", + "market": "index", + "section": "코스피200", + "label": "1열판기본_현재지수", + "detail": "코스피200", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-179", + "market": "index", + "section": "코스피200", + "label": "수익률그래프_5일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-180", + "market": "index", + "section": "코스피200", + "label": "수익률그래프_20일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-181", + "market": "index", + "section": "코스피200", + "label": "수익률그래프_60일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-182", + "market": "index", + "section": "코스피200", + "label": "수익률그래프_120일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-183", + "market": "index", + "section": "코스피200", + "label": "수익률그래프_240일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-184", + "market": "index", + "section": "코스피200", + "label": "캔들그래프_일봉", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-185", + "market": "index", + "section": "코스피200", + "label": "캔들그래프_5일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-186", + "market": "index", + "section": "코스피200", + "label": "캔들그래프_20일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-187", + "market": "index", + "section": "코스피200", + "label": "캔들그래프_60일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-188", + "market": "index", + "section": "코스피200", + "label": "캔들그래프_120일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-189", + "market": "index", + "section": "코스피200", + "label": "캔들그래프_240일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-190", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(거래량)_일봉", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-191", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(거래량)_5일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-192", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(거래량)_20일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-193", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(거래량)_60일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-194", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(거래량)_120일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-195", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(거래량)_240일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-196", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(예상지수)_5일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-197", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(예상지수)_20일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-198", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(예상지수)_60일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-199", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(예상지수)_120일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-200", + "market": "index", + "section": "코스피200", + "label": "캔들그래프(예상지수)_240일", + "detail": "코스피200", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI200_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-201", + "market": "index", + "section": "선물", + "label": "1열판기본_예상지수", + "detail": "선물", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "EXPECTED_INDEX", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-202", + "market": "index", + "section": "선물", + "label": "1열판기본_현재지수", + "detail": "선물", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-203", + "market": "index", + "section": "선물", + "label": "캔들그래프_일봉", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-204", + "market": "index", + "section": "선물", + "label": "캔들그래프_5일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-205", + "market": "index", + "section": "선물", + "label": "캔들그래프_20일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-206", + "market": "index", + "section": "선물", + "label": "캔들그래프_60일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-207", + "market": "index", + "section": "선물", + "label": "캔들그래프_120일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-208", + "market": "index", + "section": "선물", + "label": "캔들그래프_240일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-209", + "market": "index", + "section": "선물", + "label": "캔들그래프(예상지수)_5일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-210", + "market": "index", + "section": "선물", + "label": "캔들그래프(예상지수)_20일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-211", + "market": "index", + "section": "선물", + "label": "캔들그래프(예상지수)_60일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-212", + "market": "index", + "section": "선물", + "label": "캔들그래프(예상지수)_120일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-213", + "market": "index", + "section": "선물", + "label": "캔들그래프(예상지수)_240일", + "detail": "선물", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "FUTURES_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-214", + "market": "index", + "section": "KRX100", + "label": "1열판기본_예상지수", + "detail": "KRX100", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "EXPECTED_INDEX", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-215", + "market": "index", + "section": "KRX100", + "label": "1열판기본_현재지수", + "detail": "KRX100", + "category": "plate", + "builderKey": "s5001", + "code": "5001", + "aliases": [ + "5001" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100", + "subject": "INDEX", + "graphicType": "1열판기본", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-216", + "market": "index", + "section": "KRX100", + "label": "수익률그래프_5일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "FiveDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-217", + "market": "index", + "section": "KRX100", + "label": "수익률그래프_20일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-218", + "market": "index", + "section": "KRX100", + "label": "수익률그래프_60일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "SixtyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-219", + "market": "index", + "section": "KRX100", + "label": "수익률그래프_120일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "OneHundredTwentyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-220", + "market": "index", + "section": "KRX100", + "label": "수익률그래프_240일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s5086", + "code": "5086", + "aliases": [ + "5086" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100", + "subject": "INDEX", + "graphicType": "YIELD_GRAPH", + "subtype": "TwoHundredFortyDays", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-221", + "market": "index", + "section": "KRX100", + "label": "캔들그래프_일봉", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-222", + "market": "index", + "section": "KRX100", + "label": "캔들그래프_5일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-223", + "market": "index", + "section": "KRX100", + "label": "캔들그래프_20일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-224", + "market": "index", + "section": "KRX100", + "label": "캔들그래프_60일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-225", + "market": "index", + "section": "KRX100", + "label": "캔들그래프_120일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-226", + "market": "index", + "section": "KRX100", + "label": "캔들그래프_240일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "PRICE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-227", + "market": "index", + "section": "KRX100", + "label": "캔들그래프(거래량)_일봉", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8035", + "aliases": [ + "8035" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-228", + "market": "index", + "section": "KRX100", + "label": "캔들그래프(거래량)_5일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8061", + "aliases": [ + "8061" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-229", + "market": "index", + "section": "KRX100", + "label": "캔들그래프(거래량)_20일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8040", + "aliases": [ + "8040" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-230", + "market": "index", + "section": "KRX100", + "label": "캔들그래프(거래량)_60일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8046", + "aliases": [ + "8046" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-231", + "market": "index", + "section": "KRX100", + "label": "캔들그래프(거래량)_120일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8051", + "aliases": [ + "8051" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-232", + "market": "index", + "section": "KRX100", + "label": "캔들그래프(거래량)_240일", + "detail": "KRX100", + "category": "chart", + "builderKey": "s8010", + "code": "8056", + "aliases": [ + "8056" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KRX100_INDEX", + "subject": "INDEX", + "graphicType": "", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-233", + "market": "index", + "section": "매매동향", + "label": "주체별 매매동향_표그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5082", + "code": "5082", + "aliases": [ + "5082" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "TRADING_BY_PARTICIPANT", + "subtype": "TABLE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-234", + "market": "index", + "section": "매매동향", + "label": "코스피 매매동향_표그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5023", + "code": "5023", + "aliases": [ + "5023" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "KOSPI_TRADING_TREND", + "subtype": "TABLE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-235", + "market": "index", + "section": "매매동향", + "label": "코스피 매매동향_라인그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5084", + "code": "5084", + "aliases": [ + "5084" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "KOSPI_TRADING_TREND", + "subtype": "LINE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-236", + "market": "index", + "section": "매매동향", + "label": "코스피 매매동향_막대그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5024", + "code": "5024", + "aliases": [ + "5024" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSPI", + "subject": "INDEX", + "graphicType": "KOSPI_TRADING_TREND", + "subtype": "BAR_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-237", + "market": "index", + "section": "매매동향", + "label": "코스닥 매매동향_표그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5023", + "code": "5023", + "aliases": [ + "5023" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "KOSDAQ_TRADING_TREND", + "subtype": "TABLE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-238", + "market": "index", + "section": "매매동향", + "label": "코스닥 매매동향_라인그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5084", + "code": "5084", + "aliases": [ + "5084" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "KOSDAQ_TRADING_TREND", + "subtype": "LINE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-239", + "market": "index", + "section": "매매동향", + "label": "코스닥 매매동향_막대그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5024", + "code": "5024", + "aliases": [ + "5024" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "KOSDAQ", + "subject": "INDEX", + "graphicType": "KOSDAQ_TRADING_TREND", + "subtype": "BAR_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-240", + "market": "index", + "section": "매매동향", + "label": "개인 매매동향_라인그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5083", + "code": "5083", + "aliases": [ + "5083" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "INDIVIDUAL_TRADING_TREND", + "subtype": "LINE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-241", + "market": "index", + "section": "매매동향", + "label": "외국인 매매동향_라인그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5083", + "code": "5083", + "aliases": [ + "5083" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "FOREIGN_TRADING_TREND", + "subtype": "LINE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-242", + "market": "index", + "section": "매매동향", + "label": "기관 매매동향_라인그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5083", + "code": "5083", + "aliases": [ + "5083" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "INSTITUTION_TRADING_TREND", + "subtype": "LINE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-243", + "market": "index", + "section": "매매동향", + "label": "기관 순매수 현황_표그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s6067", + "code": "6067", + "aliases": [ + "6067" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "INSTITUTION_NET_BUY", + "subtype": "TABLE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-244", + "market": "index", + "section": "매매동향", + "label": "프로그램 매매_표그래프", + "detail": "매매동향", + "category": "market", + "builderKey": "s5085", + "code": "5085", + "aliases": [ + "5085" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "ALL", + "subject": "INDEX", + "graphicType": "PROGRAM_TRADING", + "subtype": "TABLE_GRAPH", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-245", + "market": "index", + "section": "매매동향", + "label": "개인 순매도 상위(수동)", + "detail": "매매동향", + "category": "market", + "builderKey": "s5025", + "code": "5025", + "aliases": [ + "5025" + ], + "available": false, + "prerequisite": "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.", + "selection": { + "groupCode": "INDIVIDUAL", + "subject": "INDEX", + "graphicType": "MANUAL_NET_SELL", + "subtype": "MANUAL_NET_SELL", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-246", + "market": "index", + "section": "매매동향", + "label": "외국인 순매도 상위(수동)", + "detail": "매매동향", + "category": "market", + "builderKey": "s5025", + "code": "5025", + "aliases": [ + "5025" + ], + "available": false, + "prerequisite": "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.", + "selection": { + "groupCode": "FOREIGN", + "subject": "INDEX", + "graphicType": "MANUAL_NET_SELL", + "subtype": "MANUAL_NET_SELL", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-247", + "market": "index", + "section": "매매동향", + "label": "기관 순매도 상위(수동)", + "detail": "매매동향", + "category": "market", + "builderKey": "s5025", + "code": "5025", + "aliases": [ + "5025" + ], + "available": false, + "prerequisite": "원본 수동 순매도 데이터 파일과 검증된 입력 화면이 필요합니다.", + "selection": { + "groupCode": "INSTITUTION", + "subject": "INDEX", + "graphicType": "MANUAL_NET_SELL", + "subtype": "MANUAL_NET_SELL", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-248", + "market": "index", + "section": "5단 표그래프", + "label": "코스피 시가총액", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-249", + "market": "index", + "section": "5단 표그래프", + "label": "코스피 예상가 시가총액 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED_MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-250", + "market": "index", + "section": "5단 표그래프", + "label": "코스피 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-251", + "market": "index", + "section": "5단 표그래프", + "label": "코스피 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-252", + "market": "index", + "section": "5단 표그래프", + "label": "코스피 시간외 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-253", + "market": "index", + "section": "5단 표그래프", + "label": "코스피 시간외 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-254", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥 시가총액", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-255", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥 예상가 시가총액 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED_MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-256", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-257", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-258", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥 시간외 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-259", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥 시간외 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-260", + "market": "index", + "section": "5단 표그래프", + "label": "코스피 200 종목", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "KOSPI200", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-261", + "market": "index", + "section": "5단 표그래프", + "label": "ETF 종목", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "ETF", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-262", + "market": "index", + "section": "5단 표그래프", + "label": "VI 발동(수동)", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": false, + "prerequisite": "검증된 VI 종목 목록 입력 화면이 필요합니다.", + "selection": { + "groupCode": "PAGED_VI", + "subject": "", + "graphicType": "INPUT_ORDER", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-263", + "market": "index", + "section": "5단 표그래프", + "label": "코스피_NXT 시가총액", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "MARKET_CAP", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-264", + "market": "index", + "section": "5단 표그래프", + "label": "코스피_NXT 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-265", + "market": "index", + "section": "5단 표그래프", + "label": "코스피_NXT 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-266", + "market": "index", + "section": "5단 표그래프", + "label": "코스피_NXT 시간외 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-267", + "market": "index", + "section": "5단 표그래프", + "label": "코스피_NXT 시간외 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-268", + "market": "index", + "section": "5단 표그래프", + "label": "코스피_NXT 거래량 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-269", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥_NXT 시가총액", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "MARKET_CAP", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-270", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥_NXT 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-271", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥_NXT 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-272", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥_NXT 시간외 상승률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-273", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥_NXT 시간외 하락률 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-274", + "market": "index", + "section": "5단 표그래프", + "label": "코스닥_NXT 거래량 상위", + "detail": "5단 표그래프", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-275", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 시가총액", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-276", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 예상가 시가총액 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED_MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-277", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 상승률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-278", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 하락률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-279", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 52주 신고가", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_HIGH", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-280", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 52주 신저가", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_LOW", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-281", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 시간외 상승률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-282", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 시간외 하락률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-283", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 시가총액", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-284", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 예상가 시가총액 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED_MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-285", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 상승률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-286", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 하락률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-287", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 52주 신고가", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_HIGH", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-288", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 52주 신저가", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_LOW", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-289", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 시간외 상승률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-290", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥 시간외 하락률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-291", + "market": "index", + "section": "6종목 현재가", + "label": "코스피 200 종목", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "KOSPI200", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-292", + "market": "index", + "section": "6종목 현재가", + "label": "ETF 종목", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "ETF", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-293", + "market": "index", + "section": "6종목 현재가", + "label": "VI 발동(수동)", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": false, + "prerequisite": "검증된 VI 종목 목록 입력 화면이 필요합니다.", + "selection": { + "groupCode": "PAGED_VI", + "subject": "", + "graphicType": "INPUT_ORDER", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-294", + "market": "index", + "section": "6종목 현재가", + "label": "코스피_NXT 시가총액", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "MARKET_CAP", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-295", + "market": "index", + "section": "6종목 현재가", + "label": "코스피_NXT 상승률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-296", + "market": "index", + "section": "6종목 현재가", + "label": "코스피_NXT 하락률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-297", + "market": "index", + "section": "6종목 현재가", + "label": "코스피_NXT 거래량 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-298", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥_NXT 시가총액", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "MARKET_CAP", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-299", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥_NXT 상승률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-300", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥_NXT 하락률 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-301", + "market": "index", + "section": "6종목 현재가", + "label": "코스닥_NXT 거래량 상위", + "detail": "6종목 현재가", + "category": "plate", + "builderKey": "s5077", + "code": "5077", + "aliases": [ + "5077" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-302", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 시가총액", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-303", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 예상가 시가총액 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED_MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-304", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 상승률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-305", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 하락률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-306", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 52주 신고가", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_HIGH", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-307", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 52주 신저가", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_LOW", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-308", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 시간외 상승률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-309", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 시간외 하락률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-310", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 시가총액", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-311", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 예상가 시가총액 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "EXPECTED_MARKET_CAP", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-312", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 상승률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-313", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 하락률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-314", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 52주 신고가", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_HIGH", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-315", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 52주 신저가", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "52_WEEK_LOW", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-316", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 시간외 상승률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_GAIN_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-317", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥 시간외 하락률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSDAQ", + "subject": "INDEX", + "graphicType": "", + "subtype": "AFTER_HOURS_LOSS_RATE", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-318", + "market": "index", + "section": "12종목 현재가", + "label": "코스피 200 종목", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "KOSPI200", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-319", + "market": "index", + "section": "12종목 현재가", + "label": "ETF 종목", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_DOMESTIC_KOSPI", + "subject": "INDEX", + "graphicType": "", + "subtype": "ETF", + "dataCode": "$TODAY" + }, + "dynamicDate": true, + "dynamicNxtSession": false + }, + { + "id": "fixed-320", + "market": "index", + "section": "12종목 현재가", + "label": "VI 발동(수동)", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5074", + "code": "5074", + "aliases": [ + "5074" + ], + "available": false, + "prerequisite": "검증된 VI 종목 목록 입력 화면이 필요합니다.", + "selection": { + "groupCode": "PAGED_VI", + "subject": "", + "graphicType": "INPUT_ORDER", + "subtype": "CURRENT", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": false + }, + { + "id": "fixed-321", + "market": "index", + "section": "12종목 현재가", + "label": "코스피_NXT 시가총액", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "MARKET_CAP", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-322", + "market": "index", + "section": "12종목 현재가", + "label": "코스피_NXT 상승률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-323", + "market": "index", + "section": "12종목 현재가", + "label": "코스피_NXT 하락률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-324", + "market": "index", + "section": "12종목 현재가", + "label": "코스피_NXT 거래량 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSPI", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-325", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥_NXT 시가총액", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "MARKET_CAP", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-326", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥_NXT 상승률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "GAIN_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-327", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥_NXT 하락률 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "LOSS_RATE", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + }, + { + "id": "fixed-328", + "market": "index", + "section": "12종목 현재가", + "label": "코스닥_NXT 거래량 상위", + "detail": "12종목 현재가", + "category": "plate", + "builderKey": "s5088", + "code": "5088", + "aliases": [ + "5088" + ], + "available": true, + "prerequisite": "", + "selection": { + "groupCode": "PAGED_NXT_KOSDAQ", + "subject": "INDEX", + "graphicType": "$NXT_SESSION", + "subtype": "VOLUME", + "dataCode": "" + }, + "dynamicDate": false, + "dynamicNxtSession": true + } + ] +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs new file mode 100644 index 0000000..ce00c50 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonProductionAdapters.cs @@ -0,0 +1,231 @@ +using System.Globalization; +using System.Text; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +/// +/// Adapts the already parameterized domestic/world lookup services to the closed UC3 +/// comparison identity model. Database identities never cross the WebView boundary. +/// +public sealed class LegacyComparisonDataService : ILegacyComparisonDataService +{ + private readonly ILegacyStockLookup _domesticLookup; + private readonly IWorldStockSearchService _worldLookup; + + public LegacyComparisonDataService( + ILegacyStockLookup domesticLookup, + IWorldStockSearchService worldLookup) + { + _domesticLookup = domesticLookup ?? throw new ArgumentNullException(nameof(domesticLookup)); + _worldLookup = worldLookup ?? throw new ArgumentNullException(nameof(worldLookup)); + } + + public async Task> SearchDomesticAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) + { + ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.DomesticMaximumResults); + var result = await _domesticLookup.SearchAsync(query, cancellationToken) + .ConfigureAwait(false); + var targets = new List( + Math.Min(result.DisplayNames.Count, maximumResults)); + var identities = new HashSet(StringComparer.Ordinal); + foreach (var displayName in result.DisplayNames) + { + cancellationToken.ThrowIfCancellationRequested(); + var identity = result.ResolveLastByDisplayName(displayName); + if (identity is null) + { + continue; + } + + var target = ToComparisonTarget(identity); + if (identities.Add(target.TargetId)) + { + targets.Add(target); + if (targets.Count == maximumResults) + { + break; + } + } + } + + return targets.AsReadOnly(); + } + + public async Task> SearchWorldAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default) + { + ValidateLimit(maximumResults, LegacyComparisonWorkflowCatalog.WorldMaximumResults); + var result = await _worldLookup.SearchAsync(query, maximumResults, cancellationToken) + .ConfigureAwait(false); + return Array.AsReadOnly(result.Items + .Select(item => LegacyComparisonTarget.CreateWorldStock( + item.InputName, + item.Symbol, + item.NationCode)) + .ToArray()); + } + + public async Task ResolveStoredTargetAsync( + LegacyComparisonStoredTargetReference target, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(target); + if (target.Market.Length == 0) + { + var world = await SearchWorldAsync( + target.Name, + LegacyComparisonWorkflowCatalog.WorldMaximumResults, + cancellationToken).ConfigureAwait(false); + return world.FirstOrDefault(item => + LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target)); + } + + var domestic = await SearchDomesticAsync( + RemoveNxtSuffix(target.Name), + LegacyComparisonWorkflowCatalog.DomesticMaximumResults, + cancellationToken).ConfigureAwait(false); + return domestic.FirstOrDefault(item => + LegacyComparisonWorkflowCatalog.MatchesStoredReference(item, target)); + } + + private static LegacyComparisonTarget ToComparisonTarget(LegacyStockIdentity identity) + { + var name = RemoveNxtSuffix(identity.DisplayName); + return identity.Market switch + { + LegacyDomesticStockMarket.Kospi => LegacyComparisonTarget.CreateKrxStock( + LegacyComparisonDomesticMarket.Kospi, + name, + identity.StockCode), + LegacyDomesticStockMarket.Kosdaq => LegacyComparisonTarget.CreateKrxStock( + LegacyComparisonDomesticMarket.Kosdaq, + name, + identity.StockCode), + LegacyDomesticStockMarket.NxtKospi => LegacyComparisonTarget.CreateNxtStock( + LegacyComparisonDomesticMarket.Kospi, + name, + identity.StockCode), + LegacyDomesticStockMarket.NxtKosdaq => LegacyComparisonTarget.CreateNxtStock( + LegacyComparisonDomesticMarket.Kosdaq, + name, + identity.StockCode), + _ => throw new InvalidOperationException("The domestic stock market is invalid.") + }; + } + + private static string RemoveNxtSuffix(string value) => + value.EndsWith("(NXT)", StringComparison.Ordinal) + ? value[..^5] + : value; + + private static void ValidateLimit(int value, int maximum) + { + if (value is < 1 || value > maximum) + { + throw new ArgumentOutOfRangeException(nameof(value)); + } + } +} + +/// +/// Trusted, fixed-path CP949 store for the original 종목비교.dat shape. Reads may fall +/// back to a read-only seed, but all writes are atomic and confined to the local path. +/// +public sealed class LegacyComparisonPairFileStore : ILegacyComparisonPairStore +{ + private readonly string _path; + private readonly string? _readOnlySeedPath; + + public LegacyComparisonPairFileStore(string path, string? readOnlySeedPath = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + _path = Path.GetFullPath(path); + _readOnlySeedPath = string.IsNullOrWhiteSpace(readOnlySeedPath) + ? null + : Path.GetFullPath(readOnlySeedPath); + } + + public async Task ReadCp949Async(CancellationToken cancellationToken = default) + { + var source = File.Exists(_path) + ? _path + : _readOnlySeedPath is not null && File.Exists(_readOnlySeedPath) + ? _readOnlySeedPath + : null; + return source is null + ? null + : await File.ReadAllBytesAsync(source, cancellationToken).ConfigureAwait(false); + } + + public async Task WriteCp949Async( + IReadOnlyList rows, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(rows); + if (rows.Count > MMoneyCoderSharp.Data.LegacyComparisonFileParser.MaximumRows) + { + throw new ArgumentOutOfRangeException(nameof(rows)); + } + + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + var cp949 = Encoding.GetEncoding( + 949, + EncoderFallback.ExceptionFallback, + DecoderFallback.ExceptionFallback); + var text = string.Join( + '\n', + rows.Select((row, index) => SerializeRow(row, index + 1))); + var bytes = cp949.GetBytes(text); + if (bytes.Length > 0) + { + _ = MMoneyCoderSharp.Data.LegacyComparisonFileParser.Parse(bytes); + } + + var directory = Path.GetDirectoryName(_path) ?? + throw new InvalidOperationException("The comparison data path has no directory."); + Directory.CreateDirectory(directory); + var temporaryPath = Path.Combine( + directory, + $".{Path.GetFileName(_path)}.{Guid.NewGuid():N}.tmp"); + try + { + await File.WriteAllBytesAsync(temporaryPath, bytes, cancellationToken) + .ConfigureAwait(false); + File.Move(temporaryPath, _path, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static string SerializeRow(LegacyComparisonPairWriteRow row, int expectedNumber) + { + ArgumentNullException.ThrowIfNull(row); + if (row.RowNumber != expectedNumber) + { + throw new InvalidOperationException("Comparison row numbers must be contiguous."); + } + + return string.Join( + '^', + row.RowNumber.ToString(CultureInfo.InvariantCulture), + $"{row.FirstName},{row.SecondName}", + row.FirstMarket, + row.SecondMarket, + string.Empty, + string.Empty, + string.Empty, + string.Empty, + string.Empty); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs new file mode 100644 index 0000000..4505940 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowCatalog.cs @@ -0,0 +1,427 @@ +using System.Collections.ObjectModel; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +internal enum LegacyComparisonActionKind +{ + TwoColumn, + Comparison, + Return +} + +internal sealed record LegacyComparisonActionDefinition( + string ActionId, + string Label, + string Section, + LegacyComparisonActionKind Kind, + string? Mode = null, + string? BuilderKey = null, + string? CutCode = null, + string? GraphicType = null, + string? Period = null); + +internal sealed record LegacyComparisonCompatibility( + bool IsAvailable, + string? Reason, + string? EffectiveMode, + string? Warning); + +internal static class LegacyComparisonWorkflowCatalog +{ + public const int DefaultFadeDuration = 6; + public const int DomesticMaximumResults = 200; + public const int WorldMaximumResults = 100; + + private static readonly ReadOnlyCollection FixedTargetList = + Array.AsReadOnly(new[] + { + Market("kospi", "코스피 지수", "Kospi"), + Market("kosdaq", "코스닥 지수", "Kosdaq"), + Market("kospi200", "코스피200 지수", "Kospi200"), + Market("futures", "선물 지수", "Futures"), + Market("krx100", "KRX100지수", "Krx100"), + Market("won-dollar", "환율-원달러", "WonDollar"), + Market("won-yen", "환율-원엔", "WonYen"), + Market("won-yuan", "환율-원위엔", "WonYuan"), + Market("won-euro", "환율-원유로", "WonEuro"), + Market("dow", "해외지수-다우", "Dow"), + Market("nasdaq", "해외지수-나스닥", "Nasdaq"), + Market("sp500", "해외지수-S&P", "Sp500"), + Market("germany-dax", "해외지수-독일", "GermanyDax"), + Market("united-kingdom-ftse", "해외지수-영국", "UnitedKingdomFtse"), + Market("france-cac", "해외지수-프랑스", "FranceCac"), + Market("nikkei", "해외지수-일본", "Nikkei"), + Market("hang-seng", "해외지수-홍콩", "HangSeng"), + Market("taiwan-weighted", "해외지수-대만", "TaiwanWeighted"), + Market("singapore-straits-times", "해외지수-싱가포르", "SingaporeStraitsTimes"), + Market("thailand-set", "해외지수-태국", "ThailandSet"), + Market("philippines-composite", "해외지수-필리핀", "PhilippinesComposite"), + Market("malaysia-klse", "해외지수-말레이시아", "MalaysiaKlse"), + Market("indonesia-composite", "해외지수-인도네시아", "IndonesiaComposite"), + Market("shanghai-composite", "해외지수-중국", "ShanghaiComposite") + }); + + private static readonly IReadOnlyDictionary FixedById = + FixedTargetList.ToDictionary(static target => target.TargetId, StringComparer.Ordinal); + + private static readonly IReadOnlyDictionary FixedByDisplayName = + FixedTargetList.ToDictionary(static target => target.DisplayName, StringComparer.Ordinal); + + private static readonly ReadOnlyCollection ActionList = + Array.AsReadOnly(new[] + { + new LegacyComparisonActionDefinition( + "two-column-expected", "2열판 예상체결", "2열판", + LegacyComparisonActionKind.TwoColumn, Mode: "EXPECTED"), + new LegacyComparisonActionDefinition( + "two-column-current", "2열판", "2열판", + LegacyComparisonActionKind.TwoColumn, Mode: "CURRENT"), + new LegacyComparisonActionDefinition( + "comparison-candle", "종목별 비교분석_캔들 그래프", "종목별 비교분석", + LegacyComparisonActionKind.Comparison, + BuilderKey: "s5026", CutCode: "5026", + GraphicType: "COMPARISON_CANDLE", Period: "FiveDays"), + new LegacyComparisonActionDefinition( + "comparison-line", "종목별 비교분석_라인 그래프", "종목별 비교분석", + LegacyComparisonActionKind.Comparison, + BuilderKey: "s5087", CutCode: "5087", + GraphicType: "COMPARISON_LINE", Period: "FiveDays"), + Return("return-5d", "5일", "FiveDays"), + Return("return-1m", "1개월", "OneMonth"), + Return("return-3m", "3개월", "ThreeMonths"), + Return("return-6m", "6개월", "SixMonths"), + Return("return-12m", "12개월", "TwelveMonths") + }); + + private static readonly IReadOnlyDictionary ActionsById = + ActionList.ToDictionary(static action => action.ActionId, StringComparer.Ordinal); + + private static readonly HashSet ExpectedMarketTargets = + new(StringComparer.Ordinal) { "Kospi", "Kosdaq", "Kospi200", "Krx100" }; + + static LegacyComparisonWorkflowCatalog() + { + if (FixedTargetList.Count != 24 || FixedById.Count != 24 || + ActionList.Count != 9 || ActionsById.Count != 9) + { + throw new InvalidOperationException("The closed UC3 comparison catalog is invalid."); + } + } + + public static IReadOnlyList FixedTargets => FixedTargetList; + + public static LegacyComparisonTarget? GetFixedTarget(string targetId) => + targetId is not null && FixedById.TryGetValue(targetId, out var target) ? target : null; + + public static LegacyComparisonTarget RequireCanonicalTarget(LegacyComparisonTarget target) + { + ArgumentNullException.ThrowIfNull(target); + if (target.Kind != LegacyComparisonTargetKind.MarketTarget) + { + return target; + } + + if (!FixedById.TryGetValue(target.TargetId, out var canonical) || canonical != target) + { + throw new InvalidOperationException("The data service returned an unknown market target."); + } + + return canonical; + } + + public static LegacyComparisonTargetView ToView( + LegacyComparisonTarget target, + string selectionId) + { + ArgumentNullException.ThrowIfNull(target); + LegacyComparisonValueGuard.RequireIdentifier(selectionId, 128, nameof(selectionId)); + var metadata = target.Kind switch + { + LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!, + LegacyComparisonTargetKind.WorldStock => $"{target.Nation} · {target.Symbol}", + LegacyComparisonTargetKind.KrxStock => + $"{MarketText(target.DomesticMarket)} KRX · {target.StockCode}", + LegacyComparisonTargetKind.NxtStock => + $"{MarketText(target.DomesticMarket)} NXT · {target.StockCode}", + _ => throw new InvalidOperationException("The comparison target kind is invalid.") + }; + return new LegacyComparisonTargetView( + selectionId, + target.DisplayName, + metadata, + target.Kind); + } + + public static LegacyComparisonStoredTargetReference ToStoredReference( + LegacyComparisonTarget target) + { + ArgumentNullException.ThrowIfNull(target); + return target.Kind switch + { + LegacyComparisonTargetKind.MarketTarget => + new LegacyComparisonStoredTargetReference(target.DisplayName, string.Empty), + LegacyComparisonTargetKind.KrxStock => + new LegacyComparisonStoredTargetReference( + target.StockName!, + target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi + ? "코스피" + : "코스닥"), + LegacyComparisonTargetKind.NxtStock => + new LegacyComparisonStoredTargetReference( + target.DisplayName, + target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi + ? "코스피_NXT" + : "코스닥_NXT"), + LegacyComparisonTargetKind.WorldStock => + new LegacyComparisonStoredTargetReference(target.InputName!, string.Empty), + _ => throw new InvalidOperationException("The comparison target kind is invalid.") + }; + } + + public static bool MatchesStoredReference( + LegacyComparisonTarget target, + LegacyComparisonStoredTargetReference stored) => + ToStoredReference(target) == stored; + + public static LegacyComparisonTarget? TryResolveFixedStoredTarget( + LegacyComparisonStoredTargetReference stored) => + stored.Market.Length == 0 && + FixedByDisplayName.TryGetValue(stored.Name, out var target) + ? target + : null; + + public static IReadOnlyList CreateActionViews( + LegacyComparisonTarget? first, + LegacyComparisonTarget? second) + { + var views = ActionList.Select(action => + { + var compatibility = GetCompatibility(action, first, second); + return new LegacyComparisonActionView( + action.ActionId, + action.Label, + action.Section, + compatibility.IsAvailable, + compatibility.Reason); + }).ToArray(); + return Array.AsReadOnly(views); + } + + public static LegacyGenericPlaylistDraft Materialize( + string actionId, + LegacyComparisonTarget first, + LegacyComparisonTarget second, + string draftId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!ActionsById.TryGetValue(actionId, out var action)) + { + throw new ArgumentOutOfRangeException(nameof(actionId), "The comparison action is unknown."); + } + + var compatibility = GetCompatibility(action, first, second); + if (!compatibility.IsAvailable) + { + throw new InvalidOperationException( + $"The selected pair cannot use this comparison action: {compatibility.Reason}."); + } + + LegacyComparisonValueGuard.RequireIdentifier(draftId, 128, nameof(draftId)); + var subject = $"{SubjectToken(first)},{SubjectToken(second)}"; + string builderKey; + string cutCode; + string graphicType; + string subtype; + string dataCode; + string category; + if (action.Kind == LegacyComparisonActionKind.TwoColumn) + { + var hasFutures = IsFutures(first) || IsFutures(second); + builderKey = hasFutures ? "s5032" : "s8018"; + cutCode = hasFutures ? "5032" : "8032"; + graphicType = "2열판"; + subtype = action.Mode!; + dataCode = string.Empty; + category = "plate"; + } + else + { + builderKey = action.Kind == LegacyComparisonActionKind.Return + ? "s5029" + : action.BuilderKey!; + cutCode = action.Kind == LegacyComparisonActionKind.Return + ? "5029" + : action.CutCode!; + graphicType = action.Kind == LegacyComparisonActionKind.Return + ? "RETURN_COMPARISON" + : action.GraphicType!; + subtype = action.Period!; + dataCode = ExactKrxIdentityEnvelope(first, second); + category = "chart"; + } + + var selection = new LegacySceneSelection( + first.Kind == LegacyComparisonTargetKind.MarketTarget || + second.Kind == LegacyComparisonTargetKind.MarketTarget + ? "INDEX" + : "STOCK", + subject, + graphicType, + subtype, + dataCode); + return new LegacyGenericPlaylistDraft( + draftId, + builderKey, + cutCode, + Array.AsReadOnly(new[] { cutCode }), + $"{first.DisplayName} ↔ {second.DisplayName}", + action.Label, + category, + "compare", + selection, + true, + DefaultFadeDuration, + "legacy-comparison-workflow", + action.ActionId, + compatibility.EffectiveMode, + compatibility.Warning); + } + + public static string PairSignature( + LegacyComparisonTarget first, + LegacyComparisonTarget second) + { + var firstStored = ToStoredReference(first); + var secondStored = ToStoredReference(second); + return string.Join( + '\u001f', + firstStored.Name, + firstStored.Market, + secondStored.Name, + secondStored.Market); + } + + private static LegacyComparisonCompatibility GetCompatibility( + LegacyComparisonActionDefinition action, + LegacyComparisonTarget? first, + LegacyComparisonTarget? second) + { + if (first is null || second is null) + { + return Unavailable("pair-incomplete-or-invalid"); + } + + if (action.Kind is LegacyComparisonActionKind.Comparison or LegacyComparisonActionKind.Return) + { + return first.Kind == LegacyComparisonTargetKind.KrxStock && + second.Kind == LegacyComparisonTargetKind.KrxStock + ? Available("GRAPH") + : Unavailable("comparison-graphs-require-two-krx-stocks"); + } + + var futuresCount = (IsFutures(first) ? 1 : 0) + (IsFutures(second) ? 1 : 0); + if (futuresCount > 1) + { + return Unavailable("two-futures-targets-are-unsupported"); + } + + if (futuresCount == 1) + { + var counterpart = IsFutures(first) ? second : first; + if (counterpart.Kind != LegacyComparisonTargetKind.MarketTarget) + { + return Unavailable("futures-requires-a-market-target-counterpart"); + } + + return action.Mode == "EXPECTED" + ? Available( + "CURRENT", + "The current Core routes a futures expected action through the current s5032 branch.") + : Available("CURRENT"); + } + + if (action.Mode == "EXPECTED") + { + var supported = IsExpectedTarget(first) && IsExpectedTarget(second); + return supported + ? Available("EXPECTED") + : Unavailable( + "expected-mode-supports-only-krx-stocks-and-four-domestic-indices"); + } + + var hasMarket = first.Kind == LegacyComparisonTargetKind.MarketTarget || + second.Kind == LegacyComparisonTargetKind.MarketTarget; + var hasWorld = first.Kind == LegacyComparisonTargetKind.WorldStock || + second.Kind == LegacyComparisonTargetKind.WorldStock; + return hasMarket && hasWorld + ? Unavailable("mixed-market-and-world-is-unsupported") + : Available("CURRENT"); + } + + private static bool IsExpectedTarget(LegacyComparisonTarget target) => + target.Kind == LegacyComparisonTargetKind.KrxStock || + target.Kind == LegacyComparisonTargetKind.MarketTarget && + ExpectedMarketTargets.Contains(target.MarketTarget!); + + private static bool IsFutures(LegacyComparisonTarget target) => + target.Kind == LegacyComparisonTargetKind.MarketTarget && + string.Equals(target.MarketTarget, "Futures", StringComparison.Ordinal); + + private static string SubjectToken(LegacyComparisonTarget target) => target.Kind switch + { + LegacyComparisonTargetKind.MarketTarget => target.MarketTarget!, + LegacyComparisonTargetKind.KrxStock => target.StockName!, + LegacyComparisonTargetKind.NxtStock => target.DisplayName, + LegacyComparisonTargetKind.WorldStock => target.InputName!, + _ => throw new InvalidOperationException("The comparison target kind is invalid.") + }; + + private static string ExactKrxIdentityEnvelope( + LegacyComparisonTarget first, + LegacyComparisonTarget second) + { + if (first.Kind != LegacyComparisonTargetKind.KrxStock || + second.Kind != LegacyComparisonTargetKind.KrxStock) + { + throw new InvalidOperationException("Comparison graphs require two KRX stocks."); + } + + return $"{MarketIdentity(first)}:{first.StockCode}|{MarketIdentity(second)}:{second.StockCode}"; + } + + private static string MarketIdentity(LegacyComparisonTarget target) => + target.DomesticMarket == LegacyComparisonDomesticMarket.Kospi ? "KOSPI" : "KOSDAQ"; + + private static string MarketText(LegacyComparisonDomesticMarket? market) => market switch + { + LegacyComparisonDomesticMarket.Kospi => "KOSPI", + LegacyComparisonDomesticMarket.Kosdaq => "KOSDAQ", + _ => throw new InvalidOperationException("The domestic comparison market is missing.") + }; + + private static LegacyComparisonCompatibility Available( + string effectiveMode, + string? warning = null) => + new(true, null, effectiveMode, warning); + + private static LegacyComparisonCompatibility Unavailable(string reason) => + new(false, reason, null, null); + + private static LegacyComparisonTarget Market( + string id, + string displayName, + string target) => + LegacyComparisonTarget.CreateMarketTarget(id, displayName, target); + + private static LegacyComparisonActionDefinition Return( + string id, + string label, + string period) => + new( + id, + label, + "종목별 수익률 비교", + LegacyComparisonActionKind.Return, + Period: period); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs new file mode 100644 index 0000000..34497e4 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowController.cs @@ -0,0 +1,664 @@ +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +/// +/// C# authority for the original UC3 comparison workflow. Search results and fixed targets +/// are selected by opaque ids, saved bytes are parsed by the strict Core CP949 parser, and +/// an action id is the only caller-supplied value used to materialize a playlist draft. +/// +public sealed class LegacyComparisonWorkflowController +{ + private readonly ILegacyComparisonDataService _dataService; + private readonly ILegacyComparisonPairStore _pairStore; + private List _domesticResults = []; + private List _worldResults = []; + private List _savedPairs = []; + private LegacyComparisonTarget? _firstTarget; + private LegacyComparisonTarget? _secondTarget; + private string? _selectedDomesticResultId; + private string? _selectedWorldResultId; + private string? _selectedFixedTargetId; + private string? _selectedPairId; + private string _domesticQuery = string.Empty; + private string _worldQuery = string.Empty; + private string _domesticError = string.Empty; + private string _worldError = string.Empty; + private string _statusMessage = string.Empty; + private LegacyComparisonSearchStatus _domesticStatus = LegacyComparisonSearchStatus.Idle; + private LegacyComparisonSearchStatus _worldStatus = LegacyComparisonSearchStatus.Idle; + private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; + private long _revision; + private long _pairSequence; + private long _draftSequence; + private int _domesticSearchVersion; + private int _worldSearchVersion; + private bool _isBusy; + + public LegacyComparisonWorkflowController( + ILegacyComparisonDataService dataService, + ILegacyComparisonPairStore pairStore) + { + _dataService = dataService ?? throw new ArgumentNullException(nameof(dataService)); + _pairStore = pairStore ?? throw new ArgumentNullException(nameof(pairStore)); + } + + public LegacyComparisonWorkflowSnapshot Current => CreateSnapshot(); + + public async Task SearchDomesticAsync( + string query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = NormalizeSearchQuery(query); + var version = ++_domesticSearchVersion; + _domesticQuery = normalizedQuery; + _domesticStatus = LegacyComparisonSearchStatus.Loading; + _domesticError = string.Empty; + _domesticResults = []; + _selectedDomesticResultId = null; + Touch(); + + try + { + var results = await _dataService.SearchDomesticAsync( + normalizedQuery, + LegacyComparisonWorkflowCatalog.DomesticMaximumResults, + cancellationToken).ConfigureAwait(false); + if (version != _domesticSearchVersion) + { + return CreateSnapshot(); + } + + _domesticResults = ValidateSearchResults( + results, + LegacyComparisonWorkflowCatalog.DomesticMaximumResults, + domestic: true, + version); + _domesticStatus = LegacyComparisonSearchStatus.Ready; + _domesticError = string.Empty; + Touch(); + return CreateSnapshot(); + } + catch (OperationCanceledException) + { + if (version == _domesticSearchVersion) + { + _domesticStatus = LegacyComparisonSearchStatus.Idle; + _domesticError = string.Empty; + Touch(); + } + + throw; + } + catch + { + if (version == _domesticSearchVersion) + { + _domesticResults = []; + _domesticStatus = LegacyComparisonSearchStatus.Error; + _domesticError = "국내·NXT 종목 검색 결과를 안전하게 확인하지 못했습니다."; + Touch(); + } + + throw; + } + } + + public async Task SearchWorldAsync( + string query, + CancellationToken cancellationToken = default) + { + // UC3's blank world query loaded the complete US/TW universe, so blank remains valid. + var normalizedQuery = NormalizeSearchQuery(query); + var version = ++_worldSearchVersion; + _worldQuery = normalizedQuery; + _worldStatus = LegacyComparisonSearchStatus.Loading; + _worldError = string.Empty; + _worldResults = []; + _selectedWorldResultId = null; + Touch(); + + try + { + var results = await _dataService.SearchWorldAsync( + normalizedQuery, + LegacyComparisonWorkflowCatalog.WorldMaximumResults, + cancellationToken).ConfigureAwait(false); + if (version != _worldSearchVersion) + { + return CreateSnapshot(); + } + + _worldResults = ValidateSearchResults( + results, + LegacyComparisonWorkflowCatalog.WorldMaximumResults, + domestic: false, + version); + _worldStatus = LegacyComparisonSearchStatus.Ready; + _worldError = string.Empty; + Touch(); + return CreateSnapshot(); + } + catch (OperationCanceledException) + { + if (version == _worldSearchVersion) + { + _worldStatus = LegacyComparisonSearchStatus.Idle; + _worldError = string.Empty; + Touch(); + } + + throw; + } + catch + { + if (version == _worldSearchVersion) + { + _worldResults = []; + _worldStatus = LegacyComparisonSearchStatus.Error; + _worldError = "미국·대만 종목 검색 결과를 안전하게 확인하지 못했습니다."; + Touch(); + } + + throw; + } + } + + public LegacyComparisonWorkflowSnapshot SelectDomesticResult(string selectionId) => + SelectSearchResult(_domesticResults, selectionId, domestic: true); + + public LegacyComparisonWorkflowSnapshot SelectWorldResult(string selectionId) => + SelectSearchResult(_worldResults, selectionId, domestic: false); + + public LegacyComparisonWorkflowSnapshot SelectFixedTarget(string targetId) + { + ArgumentNullException.ThrowIfNull(targetId); + if (LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId) is not null) + { + _selectedFixedTargetId = targetId; + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyComparisonWorkflowSnapshot ChooseDomesticResult(string selectionId) => + ChooseSearchResult(_domesticResults, selectionId, domestic: true); + + public LegacyComparisonWorkflowSnapshot ChooseWorldResult(string selectionId) => + ChooseSearchResult(_worldResults, selectionId, domestic: false); + + public LegacyComparisonWorkflowSnapshot ChooseFixedTarget(string targetId) + { + ArgumentNullException.ThrowIfNull(targetId); + var target = LegacyComparisonWorkflowCatalog.GetFixedTarget(targetId); + if (target is not null) + { + _selectedFixedTargetId = targetId; + RotateDraft(target); + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyComparisonWorkflowSnapshot SwapDraftTargets() + { + (_firstTarget, _secondTarget) = (_secondTarget, _firstTarget); + Touch(); + return CreateSnapshot(); + } + + public LegacyComparisonWorkflowSnapshot ClearDraftTargets() + { + _firstTarget = null; + _secondTarget = null; + Touch(); + return CreateSnapshot(); + } + + public async Task LoadSavedPairsAsync( + CancellationToken cancellationToken = default) + { + BeginBusy(); + try + { + var source = await _pairStore.ReadCp949Async(cancellationToken).ConfigureAwait(false); + var candidate = new List(); + if (source is { Length: > 0 }) + { + var bytes = source.ToArray(); + var rows = LegacyComparisonFileParser.Parse(bytes); + foreach (var row in rows) + { + cancellationToken.ThrowIfCancellationRequested(); + var firstReference = new LegacyComparisonStoredTargetReference( + row.First.Name, + row.First.Market); + var secondReference = new LegacyComparisonStoredTargetReference( + row.Second.Name, + row.Second.Market); + var first = await ResolveStoredTargetAsync( + firstReference, + cancellationToken).ConfigureAwait(false); + var second = await ResolveStoredTargetAsync( + secondReference, + cancellationToken).ConfigureAwait(false); + candidate.Add(new SavedPairState( + $"comparison-pair-{row.RowNumber:D8}", + first, + second)); + } + } + + _savedPairs = candidate; + _pairSequence = Math.Max(_pairSequence, candidate.Count); + _selectedPairId = candidate.FirstOrDefault()?.RowId; + _statusMessage = candidate.Count == 0 + ? "저장된 종목 비교쌍이 없습니다." + : $"저장된 종목 비교쌍 {candidate.Count}개를 불러왔습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + } + catch + { + _statusMessage = "CP949 종목비교.dat를 안전하게 불러오지 못했습니다."; + _statusKind = LegacyOperatorStatusKind.Error; + throw; + } + finally + { + EndBusy(); + } + + return CreateSnapshot(); + } + + public async Task AddDraftPairAsync( + CancellationToken cancellationToken = default) + { + if (_firstTarget is null || _secondTarget is null) + { + throw new InvalidOperationException("Select two comparison targets first."); + } + + if (_savedPairs.Count >= LegacyComparisonFileParser.MaximumRows) + { + throw new InvalidOperationException("The saved comparison pair limit was reached."); + } + + var signature = LegacyComparisonWorkflowCatalog.PairSignature( + _firstTarget, + _secondTarget); + if (_savedPairs.Any(pair => string.Equals( + LegacyComparisonWorkflowCatalog.PairSignature(pair.First, pair.Second), + signature, + StringComparison.Ordinal))) + { + throw new InvalidOperationException("The exact comparison pair is already saved."); + } + + var rowId = $"comparison-pair-{++_pairSequence:D8}"; + var candidate = new List(_savedPairs) + { + new(rowId, _firstTarget, _secondTarget) + }; + return await CommitPairsAsync( + candidate, + rowId, + "종목 비교쌍을 저장했습니다.", + cancellationToken).ConfigureAwait(false); + } + + public LegacyComparisonWorkflowSnapshot SelectSavedPair(string rowId) + { + ArgumentNullException.ThrowIfNull(rowId); + if (_savedPairs.Any(pair => string.Equals(pair.RowId, rowId, StringComparison.Ordinal))) + { + _selectedPairId = rowId; + Touch(); + } + + return CreateSnapshot(); + } + + public async Task MoveSelectedPairAsync( + LegacyComparisonPairMoveDirection direction, + CancellationToken cancellationToken = default) + { + if (!Enum.IsDefined(direction)) + { + throw new ArgumentOutOfRangeException(nameof(direction)); + } + + var index = SelectedPairIndex(); + var destination = index + (direction == LegacyComparisonPairMoveDirection.Up ? -1 : 1); + if (index < 0 || destination < 0 || destination >= _savedPairs.Count) + { + return CreateSnapshot(); + } + + var candidate = new List(_savedPairs); + (candidate[index], candidate[destination]) = (candidate[destination], candidate[index]); + return await CommitPairsAsync( + candidate, + _selectedPairId, + "종목 비교쌍 순서를 변경했습니다.", + cancellationToken).ConfigureAwait(false); + } + + public async Task DeleteSelectedPairAsync( + CancellationToken cancellationToken = default) + { + var index = SelectedPairIndex(); + if (index < 0) + { + return CreateSnapshot(); + } + + var candidate = new List(_savedPairs); + candidate.RemoveAt(index); + var nextSelection = candidate.Count == 0 + ? null + : candidate[Math.Min(index, candidate.Count - 1)].RowId; + return await CommitPairsAsync( + candidate, + nextSelection, + "선택한 종목 비교쌍을 삭제했습니다.", + cancellationToken).ConfigureAwait(false); + } + + public Task DeleteAllPairsAsync( + CancellationToken cancellationToken = default) => + _savedPairs.Count == 0 + ? Task.FromResult(CreateSnapshot()) + : CommitPairsAsync( + [], + null, + "종목 비교쌍을 모두 삭제했습니다.", + cancellationToken); + + public LegacyGenericPlaylistDraft MaterializeSelectedAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (_isBusy) + { + throw new InvalidOperationException("The comparison workflow is busy."); + } + + var index = SelectedPairIndex(); + if (index < 0) + { + throw new InvalidOperationException("Select a saved comparison pair first."); + } + + var pair = _savedPairs[index]; + var draftId = $"comparison-draft-{++_draftSequence:D8}"; + return LegacyComparisonWorkflowCatalog.Materialize( + actionId, + pair.First, + pair.Second, + draftId); + } + + private LegacyComparisonWorkflowSnapshot ChooseSearchResult( + IReadOnlyList results, + string selectionId, + bool domestic) + { + ArgumentNullException.ThrowIfNull(selectionId); + var result = results.FirstOrDefault(item => + string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)); + if (result is not null) + { + if (domestic) + { + _selectedDomesticResultId = selectionId; + } + else + { + _selectedWorldResultId = selectionId; + } + + RotateDraft(result.Target); + Touch(); + } + + return CreateSnapshot(); + } + + private LegacyComparisonWorkflowSnapshot SelectSearchResult( + IReadOnlyList results, + string selectionId, + bool domestic) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (results.Any(item => string.Equals( + item.SelectionId, + selectionId, + StringComparison.Ordinal))) + { + if (domestic) + { + _selectedDomesticResultId = selectionId; + } + else + { + _selectedWorldResultId = selectionId; + } + + Touch(); + } + + return CreateSnapshot(); + } + + private void RotateDraft(LegacyComparisonTarget selected) + { + _secondTarget = _firstTarget; + _firstTarget = selected; + } + + private async Task ResolveStoredTargetAsync( + LegacyComparisonStoredTargetReference stored, + CancellationToken cancellationToken) + { + var fixedTarget = LegacyComparisonWorkflowCatalog.TryResolveFixedStoredTarget(stored); + var resolved = fixedTarget ?? await _dataService.ResolveStoredTargetAsync( + stored, + cancellationToken).ConfigureAwait(false); + if (resolved is null) + { + throw new InvalidOperationException("A stored comparison target no longer resolves."); + } + + resolved = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(resolved); + if (!LegacyComparisonWorkflowCatalog.MatchesStoredReference(resolved, stored)) + { + throw new InvalidOperationException("A stored comparison target resolved to a different identity."); + } + + return resolved; + } + + private async Task CommitPairsAsync( + IReadOnlyList candidate, + string? nextSelection, + string successMessage, + CancellationToken cancellationToken) + { + BeginBusy(); + try + { + var writeRows = candidate.Select((pair, index) => + { + var first = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.First); + var second = LegacyComparisonWorkflowCatalog.ToStoredReference(pair.Second); + return new LegacyComparisonPairWriteRow( + index + 1, + first.Name, + first.Market, + second.Name, + second.Market); + }).ToArray(); + await _pairStore.WriteCp949Async( + Array.AsReadOnly(writeRows), + cancellationToken).ConfigureAwait(false); + + _savedPairs = candidate.ToList(); + _selectedPairId = nextSelection; + _statusMessage = successMessage; + _statusKind = LegacyOperatorStatusKind.Information; + } + catch + { + _statusMessage = "종목 비교쌍 파일 쓰기를 완료하지 못해 변경을 적용하지 않았습니다."; + _statusKind = LegacyOperatorStatusKind.Error; + throw; + } + finally + { + EndBusy(); + } + + return CreateSnapshot(); + } + + private static List ValidateSearchResults( + IReadOnlyList? results, + int maximumResults, + bool domestic, + int version) + { + if (results is null || results.Count > maximumResults) + { + throw new InvalidOperationException("The comparison search result count is invalid."); + } + + var validated = new List(results.Count); + var targetIds = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < results.Count; index++) + { + var target = LegacyComparisonWorkflowCatalog.RequireCanonicalTarget(results[index]); + var allowedKind = domestic + ? target.Kind is LegacyComparisonTargetKind.KrxStock or LegacyComparisonTargetKind.NxtStock + : target.Kind == LegacyComparisonTargetKind.WorldStock; + if (!allowedKind || !targetIds.Add(target.TargetId)) + { + throw new InvalidOperationException("The comparison search result identity is invalid."); + } + + validated.Add(new SearchResultState( + $"comparison-{(domestic ? "domestic" : "world")}-{version:D4}-{index + 1:D4}", + target)); + } + + return validated; + } + + private LegacyComparisonWorkflowSnapshot CreateSnapshot() + { + var selectedPair = SelectedPairIndex() is var selectedIndex && selectedIndex >= 0 + ? _savedPairs[selectedIndex] + : null; + var domesticResults = _domesticResults + .Select(result => LegacyComparisonWorkflowCatalog.ToView( + result.Target, + result.SelectionId)) + .ToArray(); + var worldResults = _worldResults + .Select(result => LegacyComparisonWorkflowCatalog.ToView( + result.Target, + result.SelectionId)) + .ToArray(); + var fixedTargets = LegacyComparisonWorkflowCatalog.FixedTargets + .Select(target => LegacyComparisonWorkflowCatalog.ToView(target, target.TargetId)) + .ToArray(); + var savedPairs = _savedPairs.Select((pair, index) => + new LegacyComparisonSavedPairView( + pair.RowId, + index + 1, + $"{pair.First.DisplayName} ↔ {pair.Second.DisplayName}", + LegacyComparisonWorkflowCatalog.ToView(pair.First, $"{pair.RowId}-first"), + LegacyComparisonWorkflowCatalog.ToView(pair.Second, $"{pair.RowId}-second"), + string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal))) + .ToArray(); + return new LegacyComparisonWorkflowSnapshot( + _revision, + _isBusy, + new LegacyComparisonSearchSnapshot( + _domesticQuery, + _domesticStatus, + Array.AsReadOnly(domesticResults), + _selectedDomesticResultId, + domesticResults.Length, + _domesticError), + new LegacyComparisonSearchSnapshot( + _worldQuery, + _worldStatus, + Array.AsReadOnly(worldResults), + _selectedWorldResultId, + worldResults.Length, + _worldError), + Array.AsReadOnly(fixedTargets), + _selectedFixedTargetId, + _firstTarget is null + ? null + : LegacyComparisonWorkflowCatalog.ToView(_firstTarget, "comparison-draft-first"), + _secondTarget is null + ? null + : LegacyComparisonWorkflowCatalog.ToView(_secondTarget, "comparison-draft-second"), + Array.AsReadOnly(savedPairs), + _selectedPairId, + LegacyComparisonWorkflowCatalog.CreateActionViews( + selectedPair?.First, + selectedPair?.Second), + _statusMessage, + _statusKind); + } + + private int SelectedPairIndex() => _selectedPairId is null + ? -1 + : _savedPairs.FindIndex(pair => + string.Equals(pair.RowId, _selectedPairId, StringComparison.Ordinal)); + + private static string NormalizeSearchQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + var normalized = query.Trim(); + if (normalized.Length > 64 || + normalized.Any(static character => char.IsControl(character) || char.IsSurrogate(character))) + { + throw new ArgumentException("A comparison query must be at most 64 safe characters.", nameof(query)); + } + + return normalized; + } + + private void BeginBusy() + { + if (_isBusy) + { + throw new InvalidOperationException("The comparison workflow is already busy."); + } + + _isBusy = true; + Touch(); + } + + private void EndBusy() + { + _isBusy = false; + Touch(); + } + + private void Touch() => _revision++; + + private sealed record SearchResultState( + string SelectionId, + LegacyComparisonTarget Target); + + private sealed record SavedPairState( + string RowId, + LegacyComparisonTarget First, + LegacyComparisonTarget Second); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs new file mode 100644 index 0000000..e5effb2 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Comparison/LegacyComparisonWorkflowModels.cs @@ -0,0 +1,345 @@ +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyComparisonTargetKind +{ + MarketTarget, + KrxStock, + NxtStock, + WorldStock +} + +public enum LegacyComparisonDomesticMarket +{ + Kospi, + Kosdaq +} + +public enum LegacyComparisonSearchStatus +{ + Idle, + Loading, + Ready, + Error +} + +public enum LegacyComparisonPairMoveDirection +{ + Up, + Down +} + +/// +/// Validated native comparison identity. Web code never constructs this type; adapters +/// create it from trusted DB results and the controller only exposes a display projection. +/// +public sealed record LegacyComparisonTarget +{ + private LegacyComparisonTarget( + LegacyComparisonTargetKind kind, + string targetId, + string displayName, + string? marketTarget = null, + LegacyComparisonDomesticMarket? domesticMarket = null, + string? stockName = null, + string? stockCode = null, + string? inputName = null, + string? symbol = null, + string? nation = null) + { + Kind = kind; + TargetId = targetId; + DisplayName = displayName; + MarketTarget = marketTarget; + DomesticMarket = domesticMarket; + StockName = stockName; + StockCode = stockCode; + InputName = inputName; + Symbol = symbol; + Nation = nation; + } + + public LegacyComparisonTargetKind Kind { get; } + + public string TargetId { get; } + + public string DisplayName { get; } + + public string? MarketTarget { get; } + + public LegacyComparisonDomesticMarket? DomesticMarket { get; } + + public string? StockName { get; } + + public string? StockCode { get; } + + public string? InputName { get; } + + public string? Symbol { get; } + + public string? Nation { get; } + + public static LegacyComparisonTarget CreateMarketTarget( + string targetId, + string displayName, + string marketTarget) + { + LegacyComparisonValueGuard.RequireIdentifier(targetId, 64, nameof(targetId)); + LegacyComparisonValueGuard.RequireText(displayName, 200, nameof(displayName)); + LegacyComparisonValueGuard.RequireIdentifier(marketTarget, 64, nameof(marketTarget)); + return new LegacyComparisonTarget( + LegacyComparisonTargetKind.MarketTarget, + targetId, + displayName, + marketTarget: marketTarget); + } + + public static LegacyComparisonTarget CreateKrxStock( + LegacyComparisonDomesticMarket market, + string stockName, + string stockCode) + { + ValidateDomesticStock(stockName, stockCode); + var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq"; + return new LegacyComparisonTarget( + LegacyComparisonTargetKind.KrxStock, + $"krx:{marketToken}:{stockCode}", + stockName, + domesticMarket: market, + stockName: stockName, + stockCode: stockCode); + } + + public static LegacyComparisonTarget CreateNxtStock( + LegacyComparisonDomesticMarket market, + string stockName, + string stockCode) + { + ValidateDomesticStock(stockName, stockCode); + var marketToken = market == LegacyComparisonDomesticMarket.Kospi ? "kospi" : "kosdaq"; + return new LegacyComparisonTarget( + LegacyComparisonTargetKind.NxtStock, + $"nxt:{marketToken}:{stockCode}", + $"{stockName}(NXT)", + domesticMarket: market, + stockName: stockName, + stockCode: stockCode); + } + + public static LegacyComparisonTarget CreateWorldStock( + string inputName, + string symbol, + string nation) + { + LegacyComparisonValueGuard.RequireText(inputName, 200, nameof(inputName)); + if (inputName.Contains(',', StringComparison.Ordinal)) + { + throw new ArgumentException("A world stock input name cannot contain a comma.", nameof(inputName)); + } + + LegacyComparisonValueGuard.RequireSymbol(symbol, nameof(symbol)); + var normalizedNation = LegacyComparisonValueGuard.RequireNation(nation, nameof(nation)); + return new LegacyComparisonTarget( + LegacyComparisonTargetKind.WorldStock, + $"world:{normalizedNation}:{symbol}", + inputName, + inputName: inputName, + symbol: symbol, + nation: normalizedNation); + } + + private static void ValidateDomesticStock(string stockName, string stockCode) + { + LegacyComparisonValueGuard.RequireText(stockName, 200, nameof(stockName)); + if (stockName.Contains(',', StringComparison.Ordinal) || + stockName.Contains("(NXT)", StringComparison.Ordinal)) + { + throw new ArgumentException( + "A domestic stock name cannot contain a comma or the NXT display suffix.", + nameof(stockName)); + } + + LegacyComparisonValueGuard.RequireStockCode(stockCode, nameof(stockCode)); + } +} + +public sealed record LegacyComparisonStoredTargetReference( + string Name, + string Market); + +public interface ILegacyComparisonDataService +{ + Task> SearchDomesticAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default); + + Task> SearchWorldAsync( + string query, + int maximumResults, + CancellationToken cancellationToken = default); + + Task ResolveStoredTargetAsync( + LegacyComparisonStoredTargetReference target, + CancellationToken cancellationToken = default); +} + +/// +/// File IO stays behind this boundary. A production adapter owns the fixed path and CP949 +/// encoding; no UI request can provide a path or raw serialized row. +/// +public interface ILegacyComparisonPairStore +{ + Task ReadCp949Async(CancellationToken cancellationToken = default); + + Task WriteCp949Async( + IReadOnlyList rows, + CancellationToken cancellationToken = default); +} + +public sealed record LegacyComparisonPairWriteRow( + int RowNumber, + string FirstName, + string FirstMarket, + string SecondName, + string SecondMarket); + +public sealed record LegacyComparisonTargetView( + string SelectionId, + string DisplayName, + string Metadata, + LegacyComparisonTargetKind Kind); + +public sealed record LegacyComparisonSearchSnapshot( + string Query, + LegacyComparisonSearchStatus Status, + IReadOnlyList Results, + string? SelectedResultId, + int TotalRowCount, + string ErrorMessage); + +public sealed record LegacyComparisonSavedPairView( + string RowId, + int RowNumber, + string DisplayLabel, + LegacyComparisonTargetView First, + LegacyComparisonTargetView Second, + bool IsSelected); + +public sealed record LegacyComparisonActionView( + string ActionId, + string Label, + string Section, + bool IsAvailable, + string? UnavailableReason); + +public sealed record LegacyComparisonWorkflowSnapshot( + long Revision, + bool IsBusy, + LegacyComparisonSearchSnapshot DomesticSearch, + LegacyComparisonSearchSnapshot WorldSearch, + IReadOnlyList FixedTargets, + string? SelectedFixedTargetId, + LegacyComparisonTargetView? FirstTarget, + LegacyComparisonTargetView? SecondTarget, + IReadOnlyList SavedPairs, + string? SelectedPairId, + IReadOnlyList Actions, + string StatusMessage, + LegacyOperatorStatusKind StatusKind); + +/// +/// Generic, already validated playlist materialization. Only an action id crosses the UI +/// command boundary; builder, alias, scene selection and hidden identities are produced here. +/// +public sealed record LegacyGenericPlaylistDraft( + string DraftId, + string BuilderKey, + string CutCode, + IReadOnlyList Aliases, + string Title, + string Detail, + string Category, + string Market, + LegacySceneSelection Selection, + bool IsEnabled, + int FadeDuration, + string Source, + string ActionId, + string? EffectiveMode, + string? Warning) +{ + public LegacyPlaylistEntry ToLegacyPlaylistEntry() => + new(DraftId, CutCode, IsEnabled, FadeDuration, Selection); +} + +internal static class LegacyComparisonValueGuard +{ + public static void RequireText(string value, int maximumLength, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (value.Length is <= 0 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + value.Contains('^') || + value.Any(static character => + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + return char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator; + })) + { + throw new ArgumentException("A canonical comparison text value is required.", parameterName); + } + } + + public static void RequireIdentifier(string value, int maximumLength, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (value.Length is <= 0 || value.Length > maximumLength || + value.Any(static character => + !char.IsAsciiLetterOrDigit(character) && character is not '-' and not '_')) + { + throw new ArgumentException("A safe comparison identifier is required.", parameterName); + } + } + + public static void RequireStockCode(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (value.Length is <= 0 or > 32 || + value.Any(static character => + !char.IsAsciiLetterOrDigit(character) && character is not '.' and not '-' and not '_')) + { + throw new ArgumentException("A safe stock code is required.", parameterName); + } + } + + public static void RequireSymbol(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + if (value.Length is <= 0 or > 64 || + value.Any(static character => + !char.IsAsciiLetterOrDigit(character) && + character is not '@' and not '.' and not '_' and not ':' and not '-')) + { + throw new ArgumentException("A safe world symbol is required.", parameterName); + } + } + + public static string RequireNation(string value, string parameterName) + { + ArgumentNullException.ThrowIfNull(value, parameterName); + var normalized = value.ToUpperInvariant(); + if (normalized is not ("US" or "TW")) + { + throw new ArgumentException("Only the original US and TW universe is supported.", parameterName); + } + + return normalized; + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs new file mode 100644 index 0000000..4f16669 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Expert/LegacyExpertWorkflow.cs @@ -0,0 +1,558 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyExpertRequestStatus +{ + Idle, + Loading, + Ready, + Error +} + +public sealed record LegacyExpertResultView( + string SelectionId, + string ExpertCode, + string ExpertName); + +public sealed record LegacyExpertRecommendationView( + int PlayIndex, + string StockCode, + string StockName, + decimal BuyAmount); + +public sealed record LegacyExpertSearchSnapshot( + string Query, + LegacyExpertRequestStatus Status, + IReadOnlyList Results, + bool IsTruncated, + string ErrorMessage); + +public sealed record LegacyExpertPreviewSnapshot( + LegacyExpertRequestStatus Status, + IReadOnlyList Items, + bool IsTruncated, + int PageSize, + int PageCount, + int MaximumPageCount, + string ErrorMessage); + +public sealed record LegacyExpertActionView( + string ActionId, + string Label, + bool IsAvailable); + +public sealed record LegacyExpertWorkflowSnapshot( + long Revision, + LegacyExpertSearchSnapshot Search, + LegacyExpertResultView? SelectedExpert, + LegacyExpertPreviewSnapshot Preview, + IReadOnlyList Actions, + string StatusMessage, + LegacyOperatorStatusKind StatusKind); + +public sealed record LegacyExpertPlaylistDraft( + string DraftId, + string BuilderKey, + string CutCode, + IReadOnlyList Aliases, + string Title, + string Detail, + string Category, + string Source, + string ExpertCode, + string ExpertName, + int ItemCount, + bool PreviewTruncated, + int PageSize, + int PageCount, + int MaximumPageCount, + string PageText, + LegacySceneSelection Selection, + bool IsEnabled, + int FadeDuration, + string ActionId) +{ + public LegacyPlaylistEntry ToLegacyPlaylistEntry() => + new(DraftId, CutCode, IsEnabled, FadeDuration, Selection); +} + +/// +/// Native authority for UC6's read-only expert selector and recommendation preview. +/// EList CRUD is intentionally outside this workflow. +/// +public sealed class LegacyExpertWorkflowController +{ + public const string ActionId = "five-row-current"; + public const int PageSize = 5; + public const int MaximumPageCount = 20; + public const int MaximumAccessibleItems = PageSize * MaximumPageCount; + + private const int DefaultFadeDuration = 6; + private const string ActionLabel = "전문가 추천 · 5단 표그래프 · 현재가"; + + private static readonly ReadOnlyCollection Aliases = + Array.AsReadOnly(new[] { "5074" }); + + private readonly object _gate = new(); + private readonly IExpertSelectionService _service; + private List _searchResults = []; + private ExpertState? _selected; + private List _previewItems = []; + private string _query = string.Empty; + private string _searchError = string.Empty; + private string _previewError = string.Empty; + private string _statusMessage = string.Empty; + private LegacyExpertRequestStatus _searchStatus = LegacyExpertRequestStatus.Idle; + private LegacyExpertRequestStatus _previewStatus = LegacyExpertRequestStatus.Idle; + private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; + private bool _searchTruncated; + private bool _previewTruncated; + private int _searchGeneration; + private int _previewGeneration; + private long _revision; + private long _draftSequence; + + public LegacyExpertWorkflowController(IExpertSelectionService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + public LegacyExpertWorkflowSnapshot Current + { + get + { + lock (_gate) + { + return CreateSnapshot(); + } + } + } + + public async Task SearchAsync( + string query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = NormalizeQuery(query); + int generation; + lock (_gate) + { + generation = ++_searchGeneration; + _previewGeneration++; + _query = normalizedQuery; + _searchResults = []; + _selected = null; + ResetPreview(); + _searchStatus = LegacyExpertRequestStatus.Loading; + _searchError = string.Empty; + Touch(); + } + + try + { + var result = await _service.SearchAsync( + normalizedQuery, + LegacyExpertSelectionService.MaximumResults, + cancellationToken).ConfigureAwait(false); + lock (_gate) + { + if (generation != _searchGeneration) + { + return CreateSnapshot(); + } + + _searchResults = ValidateSearchResult(result, normalizedQuery, generation); + _searchTruncated = result.IsTruncated; + _searchStatus = LegacyExpertRequestStatus.Ready; + _searchError = string.Empty; + _statusMessage = $"전문가 {_searchResults.Count}명을 조회했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + } + catch (OperationCanceledException) + { + lock (_gate) + { + if (generation == _searchGeneration) + { + _searchStatus = LegacyExpertRequestStatus.Idle; + _searchError = string.Empty; + Touch(); + } + } + + throw; + } + catch + { + lock (_gate) + { + if (generation == _searchGeneration) + { + _searchResults = []; + _searchStatus = LegacyExpertRequestStatus.Error; + _searchError = "전문가 조회 결과를 안전하게 확인하지 못했습니다."; + _statusMessage = _searchError; + _statusKind = LegacyOperatorStatusKind.Error; + Touch(); + } + } + + throw; + } + } + + public async Task SelectExpertAsync( + string selectionId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(selectionId); + ExpertState selected; + int generation; + lock (_gate) + { + selected = _searchResults.FirstOrDefault(item => + string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ?? + throw new ArgumentOutOfRangeException( + nameof(selectionId), + "The expert selection id is not in the current result set."); + _selected = selected; + generation = ++_previewGeneration; + _previewItems = []; + _previewTruncated = false; + _previewStatus = LegacyExpertRequestStatus.Loading; + _previewError = string.Empty; + Touch(); + } + + try + { + var result = await _service.PreviewAsync( + selected.Identity, + LegacyExpertSelectionService.MaximumPreviewItems, + cancellationToken).ConfigureAwait(false); + lock (_gate) + { + if (generation != _previewGeneration || + _selected?.Identity != selected.Identity) + { + return CreateSnapshot(); + } + + _previewItems = ValidatePreviewResult(result, selected.Identity); + _previewTruncated = result.IsTruncated; + _previewStatus = LegacyExpertRequestStatus.Ready; + _previewError = string.Empty; + _statusMessage = $"{selected.Identity.ExpertName} 추천 종목 {_previewItems.Count}개를 조회했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + } + catch (OperationCanceledException) + { + lock (_gate) + { + if (generation == _previewGeneration) + { + _previewStatus = LegacyExpertRequestStatus.Idle; + _previewError = string.Empty; + Touch(); + } + } + + throw; + } + catch + { + lock (_gate) + { + if (generation == _previewGeneration) + { + _previewItems = []; + _previewTruncated = false; + _previewStatus = LegacyExpertRequestStatus.Error; + _previewError = "추천 종목 preview를 안전하게 확인하지 못했습니다."; + _statusMessage = _previewError; + _statusKind = LegacyOperatorStatusKind.Error; + Touch(); + } + } + + throw; + } + } + + public LegacyExpertPlaylistDraft MaterializeSelectedAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + lock (_gate) + { + if (!string.Equals(actionId, ActionId, StringComparison.Ordinal)) + { + throw new ArgumentOutOfRangeException(nameof(actionId)); + } + + if (_selected is null || _previewStatus != LegacyExpertRequestStatus.Ready) + { + throw new InvalidOperationException("Select and preview an expert first."); + } + + var itemCount = _previewItems.Count; + if (itemCount is <= 0 or > MaximumAccessibleItems) + { + throw new InvalidOperationException( + "An expert cut requires one through 100 accessible recommendation rows."); + } + + var pageCount = checked((itemCount + PageSize - 1) / PageSize); + if (pageCount is <= 0 or > MaximumPageCount) + { + throw new InvalidOperationException("The expert PageN plan exceeds 20 pages."); + } + + var identity = _selected.Identity; + var selection = new LegacySceneSelection( + "PAGED_EXPERT", + identity.ExpertName, + "INPUT_ORDER", + "CURRENT", + identity.ExpertCode); + return new LegacyExpertPlaylistDraft( + $"expert-draft-{++_draftSequence:D8}", + "s5074", + "5074", + Aliases, + identity.ExpertName, + ActionLabel, + "expert", + "legacy-expert-workflow", + identity.ExpertCode, + identity.ExpertName, + itemCount, + _previewTruncated, + PageSize, + pageCount, + MaximumPageCount, + $"1/{pageCount}", + selection, + true, + DefaultFadeDuration, + ActionId); + } + } + + private LegacyExpertWorkflowSnapshot CreateSnapshot() + { + var searchRows = _searchResults.Select(ToView).ToArray(); + var previewRows = _previewItems.Select(item => + new LegacyExpertRecommendationView( + item.PlayIndex, + item.StockCode, + item.StockName, + item.BuyAmount)).ToArray(); + var pageCount = previewRows.Length == 0 + ? 0 + : (previewRows.Length + PageSize - 1) / PageSize; + var actionAvailable = _selected is not null && + _previewStatus == LegacyExpertRequestStatus.Ready && + previewRows.Length is > 0 and <= MaximumAccessibleItems && + pageCount <= MaximumPageCount; + return new LegacyExpertWorkflowSnapshot( + _revision, + new LegacyExpertSearchSnapshot( + _query, + _searchStatus, + Array.AsReadOnly(searchRows), + _searchTruncated, + _searchError), + _selected is null ? null : ToView(_selected), + new LegacyExpertPreviewSnapshot( + _previewStatus, + Array.AsReadOnly(previewRows), + _previewTruncated, + PageSize, + pageCount, + MaximumPageCount, + _previewError), + Array.AsReadOnly(new[] + { + new LegacyExpertActionView(ActionId, ActionLabel, actionAvailable) + }), + _statusMessage, + _statusKind); + } + + private static List ValidateSearchResult( + ExpertSearchResult result, + string expectedQuery, + int generation) + { + ArgumentNullException.ThrowIfNull(result); + if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || + result.Items is null || + result.Items.Count > LegacyExpertSelectionService.MaximumResults || + result.IsTruncated && + result.Items.Count != LegacyExpertSelectionService.MaximumResults) + { + throw new ExpertSelectionDataException("The expert search response correlation is invalid."); + } + + var states = new List(result.Items.Count); + var codes = new HashSet(StringComparer.Ordinal); + var names = new HashSet(StringComparer.Ordinal); + ExpertSelectionIdentity? previous = null; + for (var index = 0; index < result.Items.Count; index++) + { + var item = result.Items[index] ?? + throw new ExpertSelectionDataException("The expert search row is missing."); + if (item.Source != DataSourceKind.Oracle) + { + throw new ExpertSelectionDataException("Only Oracle expert identities are selectable."); + } + + ValidateExpertIdentity(item.Identity); + if (!codes.Add(item.Identity.ExpertCode) || !names.Add(item.Identity.ExpertName) || + previous is not null && CompareIdentities(previous, item.Identity) > 0) + { + throw new ExpertSelectionDataException( + "Expert identities must be unique and sorted by name then code."); + } + + states.Add(new ExpertState( + $"expert-result-{generation:D4}-{index + 1:D4}", + item.Identity)); + previous = item.Identity; + } + + return states; + } + + private static List ValidatePreviewResult( + ExpertPreviewResult result, + ExpertSelectionIdentity expectedIdentity) + { + ArgumentNullException.ThrowIfNull(result); + ValidateExpertIdentity(result.Identity); + if (result.Identity != expectedIdentity || result.Items is null || + result.Items.Count > MaximumAccessibleItems || + result.IsTruncated && result.Items.Count != MaximumAccessibleItems) + { + throw new ExpertSelectionDataException("The expert preview response correlation is invalid."); + } + + var items = new List(result.Items.Count); + var indexes = new HashSet(); + var codes = new HashSet(StringComparer.Ordinal); + var names = new HashSet(StringComparer.Ordinal); + var previousIndex = -1; + foreach (var item in result.Items) + { + if (item is null || item.PlayIndex is < 0 or > 9_999 || + item.PlayIndex <= previousIndex || + !IsAsciiAlphaNumeric(item.StockCode, 32) || + !IsCanonicalText(item.StockName, 200) || + item.BuyAmount <= 0 || item.BuyAmount > 9_007_199_254_740_991m || + decimal.Truncate(item.BuyAmount) != item.BuyAmount || + !indexes.Add(item.PlayIndex) || + !codes.Add(item.StockCode) || + !names.Add(item.StockName)) + { + throw new ExpertSelectionDataException( + "The expert preview contains an invalid or duplicate recommendation."); + } + + items.Add(item); + previousIndex = item.PlayIndex; + } + + return items; + } + + private static void ValidateExpertIdentity(ExpertSelectionIdentity identity) + { + ArgumentNullException.ThrowIfNull(identity); + if (identity.ExpertCode.Length != 4 || + identity.ExpertCode.Any(static character => !char.IsAsciiDigit(character)) || + !IsCanonicalText(identity.ExpertName, 128)) + { + throw new ExpertSelectionDataException("The expert identity is invalid."); + } + } + + private static int CompareIdentities( + ExpertSelectionIdentity left, + ExpertSelectionIdentity right) + { + var name = string.Compare(left.ExpertName, right.ExpertName, StringComparison.Ordinal); + return name != 0 + ? name + : string.Compare(left.ExpertCode, right.ExpertCode, StringComparison.Ordinal); + } + + private static LegacyExpertResultView ToView(ExpertState state) => + new(state.SelectionId, state.Identity.ExpertCode, state.Identity.ExpertName); + + private static string NormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsSafeText(query)) + { + throw new ArgumentException("The expert query contains an unsafe character.", nameof(query)); + } + + var normalized = query.Trim(); + if (normalized.Length > LegacyExpertSelectionService.MaximumQueryLength || + !IsSafeText(normalized)) + { + throw new ArgumentException("The expert query exceeds 64 safe characters.", nameof(query)); + } + + return normalized; + } + + private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) => + value is not null && value.Length is > 0 && value.Length <= maximumLength && + value.All(static character => char.IsAsciiLetterOrDigit(character)); + + private static bool IsCanonicalText(string? value, int maximumLength) => + value is not null && value.Length is > 0 && value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value); + + private static bool IsSafeText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private void ResetPreview() + { + _previewItems = []; + _previewTruncated = false; + _previewStatus = LegacyExpertRequestStatus.Idle; + _previewError = string.Empty; + } + + private void Touch() => _revision++; + + private sealed record ExpertState( + string SelectionId, + ExpertSelectionIdentity Identity); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs new file mode 100644 index 0000000..3287e96 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyFixedActionCatalog.cs @@ -0,0 +1,809 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.Json.Serialization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyFixedMarket +{ + Overseas, + Exchange, + Index +} + +public enum LegacyFixedCategory +{ + Plate, + Market, + Chart +} + +public sealed record LegacyFixedSelection( + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string DataCode); + +public readonly record struct LegacyMovingAverageSelection(bool Ma5, bool Ma20); + +public sealed record LegacyFixedRuntimeAsset( + LegacyFixedMarket Market, + string RelativePath, + string Sha256); + +public sealed class LegacyFixedAction +{ + internal LegacyFixedAction( + string id, + LegacyFixedMarket market, + string section, + string label, + string detail, + LegacyFixedCategory category, + string builderKey, + string code, + IReadOnlyList aliases, + bool available, + string prerequisite, + LegacyFixedSelection selection, + bool dynamicDate, + bool dynamicNxtSession) + { + Id = id; + Market = market; + Section = section; + Label = label; + Detail = detail; + Category = category; + BuilderKey = builderKey; + Code = code; + Aliases = aliases; + Available = available; + Prerequisite = prerequisite; + Selection = selection; + DynamicDate = dynamicDate; + DynamicNxtSession = dynamicNxtSession; + } + + public string Id { get; } + + public LegacyFixedMarket Market { get; } + + public string Section { get; } + + public string Label { get; } + + public string Detail { get; } + + public LegacyFixedCategory Category { get; } + + public string BuilderKey { get; } + + public string Code { get; } + + public IReadOnlyList Aliases { get; } + + public bool Available { get; } + + public string Prerequisite { get; } + + public LegacyFixedSelection Selection { get; } + + public bool DynamicDate { get; } + + public bool DynamicNxtSession { get; } + + public bool SupportsMovingAverages => + string.Equals(BuilderKey, "s8010", StringComparison.Ordinal); +} + +public sealed record LegacyMaterializedFixedAction( + LegacyFixedAction Action, + LegacyFixedSelection Selection, + LegacyMovingAverageSelection MovingAverages, + LegacyFixedRuntimeAsset RuntimeAsset); + +public sealed class LegacyFixedSection +{ + internal LegacyFixedSection( + LegacyFixedMarket market, + string name, + IReadOnlyList actions) + { + Market = market; + Name = name; + Actions = actions; + } + + public LegacyFixedMarket Market { get; } + + public string Name { get; } + + public IReadOnlyList Actions { get; } +} + +/// +/// C#-authoritative catalog for the three fixed legacy UC1 tabs. The embedded JSON is a +/// generated build asset; callers can select only by the opaque action id and cannot provide +/// builder, cut alias, or scene-selection values. +/// +public sealed class LegacyFixedActionCatalog +{ + public const int ExpectedActionCount = 328; + public const int ExpectedOverseasCount = 78; + public const int ExpectedExchangeCount = 46; + public const int ExpectedIndexCount = 204; + public const int ExpectedAvailableCount = 322; + public const string ExpectedDocumentSha256 = + "E56FB9DE8247672022EE2F324AF4C37179EC58E399758425AD4C29DAF1BDDC56"; + public const string ExpectedGeneratorSourceSha256 = + "31D1B0F81A53FF47700D179E45BEFCA55342D11EAD3A58E67F6A926607A874B5"; + + private const string ResourceName = + "MBN_STOCK_WEBVIEW.LegacyApplication.Catalog.LegacyFixedActions.json"; + private const string ExpectedGeneratedFrom = "Web/legacy-fixed-workflow.js"; + + private static readonly JsonSerializerOptions StrictJsonOptions = new() + { + AllowTrailingCommas = false, + PropertyNameCaseInsensitive = false, + ReadCommentHandling = JsonCommentHandling.Disallow, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow + }; + + private static readonly Lazy EmbeddedCatalog = + new(LoadEmbedded, LazyThreadSafetyMode.ExecutionAndPublication); + + private readonly IReadOnlyDictionary _byId; + private readonly IReadOnlyDictionary> + _byMarket; + private readonly IReadOnlyDictionary> + _sectionsByMarket; + private readonly IReadOnlyDictionary<(LegacyFixedMarket Market, string Section), + IReadOnlyList> _bySection; + + private LegacyFixedActionCatalog( + IReadOnlyList actions, + IReadOnlyDictionary runtimeAssets, + string documentSha256, + string generatorSourceSha256) + { + Actions = actions; + RuntimeAssets = runtimeAssets; + DocumentSha256 = documentSha256; + GeneratorSourceSha256 = generatorSourceSha256; + + _byId = new ReadOnlyDictionary( + actions.ToDictionary(action => action.Id, StringComparer.Ordinal)); + + _byMarket = new ReadOnlyDictionary>( + Enum.GetValues().ToDictionary( + market => market, + market => ReadOnly(actions.Where(action => action.Market == market)))); + + var sectionsByMarket = new Dictionary>(); + var bySection = new Dictionary<(LegacyFixedMarket Market, string Section), + IReadOnlyList>(); + foreach (var market in Enum.GetValues()) + { + var sections = _byMarket[market] + .GroupBy(action => action.Section, StringComparer.Ordinal) + .Select(group => + { + var sectionActions = ReadOnly(group); + bySection.Add((market, group.Key), sectionActions); + return new LegacyFixedSection(market, group.Key, sectionActions); + }); + sectionsByMarket.Add(market, ReadOnly(sections)); + } + + _sectionsByMarket = + new ReadOnlyDictionary>( + sectionsByMarket); + _bySection = new ReadOnlyDictionary<(LegacyFixedMarket Market, string Section), + IReadOnlyList>(bySection); + } + + public static LegacyFixedActionCatalog Default => EmbeddedCatalog.Value; + + public IReadOnlyList Actions { get; } + + public IReadOnlyDictionary RuntimeAssets { get; } + + public string DocumentSha256 { get; } + + public string GeneratorSourceSha256 { get; } + + public IReadOnlyList GetActions(LegacyFixedMarket market) + { + RequireMarket(market); + return _byMarket[market]; + } + + public IReadOnlyList GetSections(LegacyFixedMarket market) + { + RequireMarket(market); + return _sectionsByMarket[market]; + } + + public IReadOnlyList GetActions( + LegacyFixedMarket market, + string section) + { + RequireMarket(market); + ArgumentException.ThrowIfNullOrWhiteSpace(section); + return _bySection.TryGetValue((market, section), out var actions) + ? actions + : Array.Empty(); + } + + public LegacyFixedAction? FindAction(string? actionId) => + actionId is not null && _byId.TryGetValue(actionId, out var action) + ? action + : null; + + public LegacyFixedAction GetAction(string actionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(actionId); + return _byId.TryGetValue(actionId, out var action) + ? action + : throw new KeyNotFoundException($"Unknown fixed legacy action id '{actionId}'."); + } + + public LegacyFixedRuntimeAsset GetRuntimeAsset(LegacyFixedMarket market) + { + RequireMarket(market); + return RuntimeAssets[market]; + } + + public LegacyMaterializedFixedAction Materialize( + string actionId, + DateTimeOffset localNow, + LegacyMovingAverageSelection movingAverages = default) + { + var action = GetAction(actionId); + if (!action.Available) + { + throw new InvalidOperationException(action.Prerequisite); + } + + var trustedMovingAverages = action.SupportsMovingAverages + ? movingAverages + : default; + var graphicType = action.DynamicNxtSession + ? localNow.Hour <= 12 ? "PRE_MARKET" : "AFTER_MARKET" + : action.SupportsMovingAverages + ? MovingAverageText(trustedMovingAverages) + : action.Selection.GraphicType; + var dataCode = action.DynamicDate + ? localNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) + : action.Selection.DataCode; + + var selection = action.Selection with + { + GraphicType = graphicType, + DataCode = dataCode + }; + return new LegacyMaterializedFixedAction( + action, + selection, + trustedMovingAverages, + RuntimeAssets[action.Market]); + } + + internal static LegacyFixedActionCatalog LoadForValidation( + ReadOnlyMemory utf8Json, + string? expectedDocumentSha256 = null) => + Load(utf8Json.ToArray(), expectedDocumentSha256); + + private static LegacyFixedActionCatalog LoadEmbedded() + { + using var stream = typeof(LegacyFixedActionCatalog).Assembly + .GetManifestResourceStream(ResourceName) + ?? throw new InvalidDataException( + $"The embedded fixed legacy catalog '{ResourceName}' is missing."); + using var buffer = new MemoryStream(); + stream.CopyTo(buffer); + return Load(buffer.ToArray(), ExpectedDocumentSha256); + } + + private static LegacyFixedActionCatalog Load(byte[] utf8Json, string? expectedDocumentSha256) + { + ArgumentNullException.ThrowIfNull(utf8Json); + if (utf8Json.Length == 0) + { + throw new InvalidDataException("The fixed legacy catalog is empty."); + } + + CatalogDocumentDto document; + try + { + using var parsed = JsonDocument.Parse(utf8Json, new JsonDocumentOptions + { + AllowTrailingCommas = false, + CommentHandling = JsonCommentHandling.Disallow + }); + RejectDuplicateProperties(parsed.RootElement, "$root"); + document = JsonSerializer.Deserialize(utf8Json, StrictJsonOptions) + ?? throw new InvalidDataException("The fixed legacy catalog root is null."); + } + catch (JsonException exception) + { + throw new InvalidDataException("The fixed legacy catalog JSON schema is invalid.", exception); + } + + var documentHash = Convert.ToHexString(SHA256.HashData(utf8Json)); + if (expectedDocumentSha256 is not null && + !string.Equals(documentHash, expectedDocumentSha256, StringComparison.Ordinal)) + { + throw new InvalidDataException( + $"The fixed legacy catalog hash is invalid. Expected {expectedDocumentSha256}; actual {documentHash}."); + } + + ValidateDocumentMetadata(document); + var runtimeAssets = CreateRuntimeAssets(document.RuntimeAssets); + var actions = CreateActions(document.Actions); + ValidateCatalogInvariants(actions); + return new LegacyFixedActionCatalog( + ReadOnly(actions), + runtimeAssets, + documentHash, + document.SourceSha256); + } + + private static void ValidateDocumentMetadata(CatalogDocumentDto document) + { + if (document.SchemaVersion != 1) + { + throw Invalid("schemaVersion must be 1"); + } + + if (!string.Equals(document.GeneratedFrom, ExpectedGeneratedFrom, StringComparison.Ordinal)) + { + throw Invalid($"generatedFrom must be '{ExpectedGeneratedFrom}'"); + } + + if (!string.Equals( + document.SourceSha256, + ExpectedGeneratorSourceSha256, + StringComparison.Ordinal)) + { + throw Invalid("the generator source hash is not approved"); + } + + if (document.RuntimeAssets is null) + { + throw Invalid("runtimeAssets is required"); + } + + if (document.Actions is null) + { + throw Invalid("actions is required"); + } + } + + private static IReadOnlyDictionary + CreateRuntimeAssets(RuntimeAssetsDto runtimeAssets) + { + var assets = new Dictionary + { + [LegacyFixedMarket.Overseas] = ValidateRuntimeAsset( + LegacyFixedMarket.Overseas, + runtimeAssets.Overseas, + "bin/Debug/Res/해외.ini", + "DBDC4EAC28BED9FD0DEABED7FE880F21CB49E48503FD3CCB95C0A29128818B2A"), + [LegacyFixedMarket.Exchange] = ValidateRuntimeAsset( + LegacyFixedMarket.Exchange, + runtimeAssets.Exchange, + "bin/Debug/Res/환율.ini", + "7A99F8DFB5DD8D8DB514605F232A0A7973545CA6A4A46ABE2D102ADD025B586C"), + [LegacyFixedMarket.Index] = ValidateRuntimeAsset( + LegacyFixedMarket.Index, + runtimeAssets.Index, + "bin/Debug/Res/지수.ini", + "E90ED317FD3ECC1A716AD17DD9BC21FE8B838574E1C20854BD0A886A96BA17E6") + }; + return new ReadOnlyDictionary(assets); + } + + private static LegacyFixedRuntimeAsset ValidateRuntimeAsset( + LegacyFixedMarket market, + RuntimeAssetDto asset, + string expectedPath, + string expectedSha256) + { + if (asset is null || + !string.Equals(asset.RelativePath, expectedPath, StringComparison.Ordinal) || + !string.Equals(asset.Sha256, expectedSha256, StringComparison.Ordinal)) + { + throw Invalid($"runtime asset metadata for '{MarketText(market)}' is invalid"); + } + + RequireSha256(asset.Sha256, $"runtimeAssets.{MarketText(market)}.sha256"); + return new LegacyFixedRuntimeAsset(market, asset.RelativePath, asset.Sha256); + } + + private static LegacyFixedAction[] CreateActions(IReadOnlyList source) + { + if (source.Count != ExpectedActionCount) + { + throw Invalid($"exactly {ExpectedActionCount} actions are required"); + } + + var actions = new LegacyFixedAction[source.Count]; + for (var index = 0; index < source.Count; index++) + { + var dto = source[index] + ?? throw Invalid($"action at index {index} is null"); + var expectedId = $"fixed-{index + 1:000}"; + if (!string.Equals(dto.Id, expectedId, StringComparison.Ordinal)) + { + throw Invalid($"action id at index {index} must be '{expectedId}'"); + } + + var market = ParseMarket(dto.Market); + var category = ParseCategory(dto.Category); + var section = RequireText(dto.Section, $"{expectedId}.section"); + var label = RequireText(dto.Label, $"{expectedId}.label"); + var detail = RequireText(dto.Detail, $"{expectedId}.detail"); + var builderKey = RequireText(dto.BuilderKey, $"{expectedId}.builderKey"); + var code = RequireText(dto.Code, $"{expectedId}.code"); + ValidateSceneIdentity(expectedId, builderKey, code, dto.Aliases); + var selection = ValidateSelection(expectedId, dto); + + var prerequisite = RequireText( + dto.Prerequisite, + $"{expectedId}.prerequisite", + allowEmpty: true, + maximumLength: 512); + if (dto.Available != string.IsNullOrEmpty(prerequisite)) + { + throw Invalid($"{expectedId} availability and prerequisite do not agree"); + } + + actions[index] = new LegacyFixedAction( + expectedId, + market, + section, + label, + detail, + category, + builderKey, + code, + Array.AsReadOnly(dto.Aliases.ToArray()), + dto.Available, + prerequisite, + selection, + dto.DynamicDate, + dto.DynamicNxtSession); + } + + return actions; + } + + private static void ValidateSceneIdentity( + string actionId, + string builderKey, + string code, + IReadOnlyList aliases) + { + if (aliases is null) + { + throw Invalid($"{actionId}.aliases is required"); + } + var definition = LegacySceneCatalog.Definitions.SingleOrDefault(candidate => + string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal)) + ?? throw Invalid($"{actionId} has unknown builder '{builderKey}'"); + + if (!definition.CutAliases.Contains(code, StringComparer.Ordinal)) + { + throw Invalid($"{actionId} code '{code}' is not an alias of '{builderKey}'"); + } + + if (aliases.Count != 1 || !string.Equals(aliases[0], code, StringComparison.Ordinal)) + { + throw Invalid($"{actionId} must declare exactly its code as its sole alias"); + } + + var aliasMatches = LegacySceneCatalog.FindByCutAlias(code); + if (!aliasMatches.Any(candidate => + string.Equals(candidate.BuilderKey, builderKey, StringComparison.Ordinal))) + { + throw Invalid($"{actionId} alias '{code}' does not resolve to '{builderKey}'"); + } + } + + private static LegacyFixedSelection ValidateSelection(string actionId, ActionDto action) + { + var source = action.Selection + ?? throw Invalid($"{actionId}.selection is required"); + var selection = new LegacyFixedSelection( + RequireText(source.GroupCode, $"{actionId}.selection.groupCode"), + RequireText(source.Subject, $"{actionId}.selection.subject", allowEmpty: true), + RequireText(source.GraphicType, $"{actionId}.selection.graphicType", allowEmpty: true), + RequireText(source.Subtype, $"{actionId}.selection.subtype", allowEmpty: true), + RequireText(source.DataCode, $"{actionId}.selection.dataCode", allowEmpty: true)); + + if (action.DynamicDate && action.DynamicNxtSession) + { + throw Invalid($"{actionId} cannot have two dynamic selection fields"); + } + + if (action.DynamicDate != + string.Equals(selection.DataCode, "$TODAY", StringComparison.Ordinal)) + { + throw Invalid($"{actionId} has an invalid dynamic-date declaration"); + } + + if (action.DynamicNxtSession != + string.Equals(selection.GraphicType, "$NXT_SESSION", StringComparison.Ordinal)) + { + throw Invalid($"{actionId} has an invalid NXT-session declaration"); + } + + if (string.Equals(action.BuilderKey, "s8010", StringComparison.Ordinal) && + !string.IsNullOrEmpty(selection.GraphicType)) + { + throw Invalid($"{actionId} moving-average flags must be materialized by C#"); + } + + var values = new[] + { + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + selection.DataCode + }; + if (values.Any(value => value.Contains('$', StringComparison.Ordinal)) && + !action.DynamicDate && !action.DynamicNxtSession) + { + throw Invalid($"{actionId} contains an unapproved selection placeholder"); + } + + return selection; + } + + private static void ValidateCatalogInvariants(IReadOnlyList actions) + { + RequireCount(actions, LegacyFixedMarket.Overseas, ExpectedOverseasCount); + RequireCount(actions, LegacyFixedMarket.Exchange, ExpectedExchangeCount); + RequireCount(actions, LegacyFixedMarket.Index, ExpectedIndexCount); + if (actions.Count(action => action.Available) != ExpectedAvailableCount) + { + throw Invalid($"exactly {ExpectedAvailableCount} actions must be available"); + } + + if (actions.Take(ExpectedOverseasCount).Any(action => + action.Market != LegacyFixedMarket.Overseas) || + actions.Skip(ExpectedOverseasCount).Take(ExpectedExchangeCount).Any(action => + action.Market != LegacyFixedMarket.Exchange) || + actions.Skip(ExpectedOverseasCount + ExpectedExchangeCount).Any(action => + action.Market != LegacyFixedMarket.Index)) + { + throw Invalid("market action ranges are not in the approved legacy order"); + } + } + + private static void RequireCount( + IEnumerable actions, + LegacyFixedMarket market, + int expected) + { + var actual = actions.Count(action => action.Market == market); + if (actual != expected) + { + throw Invalid( + $"market '{MarketText(market)}' must contain {expected} actions, not {actual}"); + } + } + + private static string MovingAverageText(LegacyMovingAverageSelection movingAverages) + { + if (movingAverages.Ma5 && movingAverages.Ma20) + { + return "MA5,MA20"; + } + + if (movingAverages.Ma5) + { + return "MA5"; + } + + return movingAverages.Ma20 ? "MA20" : string.Empty; + } + + private static LegacyFixedMarket ParseMarket(string value) => value switch + { + "overseas" => LegacyFixedMarket.Overseas, + "exchange" => LegacyFixedMarket.Exchange, + "index" => LegacyFixedMarket.Index, + _ => throw Invalid($"unknown market '{value}'") + }; + + private static LegacyFixedCategory ParseCategory(string value) => value switch + { + "plate" => LegacyFixedCategory.Plate, + "market" => LegacyFixedCategory.Market, + "chart" => LegacyFixedCategory.Chart, + _ => throw Invalid($"unknown category '{value}'") + }; + + private static string MarketText(LegacyFixedMarket market) => market switch + { + LegacyFixedMarket.Overseas => "overseas", + LegacyFixedMarket.Exchange => "exchange", + LegacyFixedMarket.Index => "index", + _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market.") + }; + + private static void RequireMarket(LegacyFixedMarket market) + { + if (!Enum.IsDefined(market)) + { + throw new ArgumentOutOfRangeException(nameof(market), market, "Unknown market."); + } + } + + private static string RequireText( + string value, + string field, + bool allowEmpty = false, + int maximumLength = 256) + { + if (value is null || (!allowEmpty && value.Length == 0) || + value.Length > maximumLength || value.Any(char.IsControl) || + !string.Equals(value, value.Trim(), StringComparison.Ordinal)) + { + throw Invalid($"{field} is invalid"); + } + + return value; + } + + private static void RequireSha256(string value, string field) + { + if (value is null || value.Length != 64 || value.Any(character => + !(char.IsAsciiDigit(character) || character is >= 'A' and <= 'F'))) + { + throw Invalid($"{field} is not an uppercase SHA-256 value"); + } + } + + private static void RejectDuplicateProperties(JsonElement element, string path) + { + if (element.ValueKind == JsonValueKind.Object) + { + var names = new HashSet(StringComparer.Ordinal); + foreach (var property in element.EnumerateObject()) + { + if (!names.Add(property.Name)) + { + throw new JsonException($"Duplicate property '{property.Name}' at {path}."); + } + + RejectDuplicateProperties(property.Value, $"{path}.{property.Name}"); + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + var index = 0; + foreach (var item in element.EnumerateArray()) + { + RejectDuplicateProperties(item, $"{path}[{index++}]"); + } + } + } + + private static IReadOnlyList ReadOnly(IEnumerable source) => + Array.AsReadOnly(source.ToArray()); + + private static InvalidDataException Invalid(string message) => + new($"The fixed legacy catalog is invalid: {message}."); + + private sealed class CatalogDocumentDto + { + [JsonPropertyName("schemaVersion")] + public required int SchemaVersion { get; init; } + + [JsonPropertyName("generatedFrom")] + public required string GeneratedFrom { get; init; } + + [JsonPropertyName("sourceSha256")] + public required string SourceSha256 { get; init; } + + [JsonPropertyName("runtimeAssets")] + public required RuntimeAssetsDto RuntimeAssets { get; init; } + + [JsonPropertyName("actions")] + public required List Actions { get; init; } + } + + private sealed class RuntimeAssetsDto + { + [JsonPropertyName("overseas")] + public required RuntimeAssetDto Overseas { get; init; } + + [JsonPropertyName("exchange")] + public required RuntimeAssetDto Exchange { get; init; } + + [JsonPropertyName("index")] + public required RuntimeAssetDto Index { get; init; } + } + + private sealed class RuntimeAssetDto + { + [JsonPropertyName("relativePath")] + public required string RelativePath { get; init; } + + [JsonPropertyName("sha256")] + public required string Sha256 { get; init; } + } + + private sealed class ActionDto + { + [JsonPropertyName("id")] + public required string Id { get; init; } + + [JsonPropertyName("market")] + public required string Market { get; init; } + + [JsonPropertyName("section")] + public required string Section { get; init; } + + [JsonPropertyName("label")] + public required string Label { get; init; } + + [JsonPropertyName("detail")] + public required string Detail { get; init; } + + [JsonPropertyName("category")] + public required string Category { get; init; } + + [JsonPropertyName("builderKey")] + public required string BuilderKey { get; init; } + + [JsonPropertyName("code")] + public required string Code { get; init; } + + [JsonPropertyName("aliases")] + public required List Aliases { get; init; } + + [JsonPropertyName("available")] + public required bool Available { get; init; } + + [JsonPropertyName("prerequisite")] + public required string Prerequisite { get; init; } + + [JsonPropertyName("selection")] + public required SelectionDto Selection { get; init; } + + [JsonPropertyName("dynamicDate")] + public required bool DynamicDate { get; init; } + + [JsonPropertyName("dynamicNxtSession")] + public required bool DynamicNxtSession { get; init; } + } + + private sealed class SelectionDto + { + [JsonPropertyName("groupCode")] + public required string GroupCode { get; init; } + + [JsonPropertyName("subject")] + public required string Subject { get; init; } + + [JsonPropertyName("graphicType")] + public required string GraphicType { get; init; } + + [JsonPropertyName("subtype")] + public required string Subtype { get; init; } + + [JsonPropertyName("dataCode")] + public required string DataCode { get; init; } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustryActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustryActionCatalog.cs new file mode 100644 index 0000000..3f357bb --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustryActionCatalog.cs @@ -0,0 +1,267 @@ +using MMoneyCoderSharp.Data; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyIndustryActionKind +{ + Single, + Pair, + Yield, + Candle, + FiveRow, + Square, + Sector +} + +public enum LegacyIndustryCandleMode +{ + Price, + Volume +} + +public sealed record LegacyIndustryRuntimeAsset( + IndustryMarket Market, + string RelativePath, + string Sha256); + +public sealed record LegacyIndustryActionDefinition( + IndustryMarket Market, + string ActionId, + string Label, + string Section, + string Category, + LegacyIndustryActionKind Kind, + string BuilderKey, + string SceneAlias, + int PageSize, + string? YieldPeriod = null, + LegacyIndustryCandleMode? CandleMode = null); + +/// +/// Closed projection of the two runtime industry INI files. The WebView supplies an +/// action id only; market-specific aliases and page sizes remain authoritative in C#. +/// +public static class LegacyIndustryActionCatalog +{ + public const int ActionsPerMarket = 22; + + private static readonly LegacyIndustryRuntimeAsset KospiAsset = new( + IndustryMarket.Kospi, + "bin/Debug/Res/업종_코스피.ini", + "F21DE58003315EAB41F9FE7B5A573C71653056203E6743D05AA96E7FF1B8BF52"); + + private static readonly LegacyIndustryRuntimeAsset KosdaqAsset = new( + IndustryMarket.Kosdaq, + "bin/Debug/Res/업종_코스닥.ini", + "F25B8926E8F20FC5FBC94A1891A9E2EAD22852E8C829321EF1A001FE3F42ECE4"); + + private static readonly IReadOnlyList KospiActions = + CreateActions(IndustryMarket.Kospi); + + private static readonly IReadOnlyList KosdaqActions = + CreateActions(IndustryMarket.Kosdaq); + + private static readonly IReadOnlyDictionary + KospiActionsById = CreateLookup(KospiActions); + + private static readonly IReadOnlyDictionary + KosdaqActionsById = CreateLookup(KosdaqActions); + + public static LegacyIndustryRuntimeAsset GetRuntimeAsset(IndustryMarket market) => market switch + { + IndustryMarket.Kospi => KospiAsset, + IndustryMarket.Kosdaq => KosdaqAsset, + _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.") + }; + + public static IReadOnlyList GetActions( + IndustryMarket market) => market switch + { + IndustryMarket.Kospi => KospiActions, + IndustryMarket.Kosdaq => KosdaqActions, + _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.") + }; + + public static bool TryGetAction( + IndustryMarket market, + string actionId, + out LegacyIndustryActionDefinition? definition) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!IsSafeActionId(actionId)) + { + definition = null; + return false; + } + + return LookupFor(market).TryGetValue(actionId, out definition); + } + + public static LegacyIndustryActionDefinition GetRequiredAction( + IndustryMarket market, + string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + return IsSafeActionId(actionId) && + LookupFor(market).TryGetValue(actionId, out var definition) + ? definition + : throw new KeyNotFoundException("The industry action id is not registered."); + } + + private static IReadOnlyDictionary LookupFor( + IndustryMarket market) => market switch + { + IndustryMarket.Kospi => KospiActionsById, + IndustryMarket.Kosdaq => KosdaqActionsById, + _ => throw new ArgumentOutOfRangeException(nameof(market), market, "Unsupported industry market.") + }; + + private static bool IsSafeActionId(string actionId) => + actionId.Length is > 0 and <= 64 && + !actionId.Any(char.IsControl) && + string.Equals(actionId, actionId.Trim(), StringComparison.Ordinal); + + private static IReadOnlyList CreateActions( + IndustryMarket market) + { + var pairAlias = market == IndustryMarket.Kospi ? "8018" : "8032"; + var squareAlias = market == IndustryMarket.Kospi ? "8001" : "8002"; + var actions = new List(ActionsPerMarket) + { + Action(market, "one-column", "1열판", "업종 판", "plate", + LegacyIndustryActionKind.Single, "s5001", "5001"), + Action(market, "two-column", "2열판", "업종 판", "plate", + LegacyIndustryActionKind.Pair, "s8018", pairAlias) + }; + + AddYield(actions, market, "5일", "FiveDays"); + AddYield(actions, market, "20일", "TwentyDays"); + AddYield(actions, market, "60일", "SixtyDays"); + AddYield(actions, market, "120일", "OneHundredTwentyDays"); + AddYield(actions, market, "240일", "TwoHundredFortyDays"); + + AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Price); + AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Price); + AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Price); + AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Price); + AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Price); + AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Price); + + AddCandle(actions, market, "일봉", "8035", LegacyIndustryCandleMode.Volume); + AddCandle(actions, market, "5일", "8061", LegacyIndustryCandleMode.Volume); + AddCandle(actions, market, "20일", "8040", LegacyIndustryCandleMode.Volume); + AddCandle(actions, market, "60일", "8046", LegacyIndustryCandleMode.Volume); + AddCandle(actions, market, "120일", "8051", LegacyIndustryCandleMode.Volume); + AddCandle(actions, market, "240일", "8056", LegacyIndustryCandleMode.Volume); + + actions.Add(Action(market, "five-row", "5단 표그래프", "시장 전체", "plate", + LegacyIndustryActionKind.FiveRow, "s5074", "5074", pageSize: 5)); + actions.Add(Action(market, "square", "네모그래프", "시장 전체", "plate", + LegacyIndustryActionKind.Square, "s8001", squareAlias)); + actions.Add(Action(market, "sector", "섹터지수", "시장 전체", "plate", + LegacyIndustryActionKind.Sector, "s5078", "5078")); + + Validate(actions, market); + return Array.AsReadOnly(actions.ToArray()); + } + + private static void AddYield( + ICollection actions, + IndustryMarket market, + string periodLabel, + string period) + { + actions.Add(Action( + market, + $"yield-{periodLabel}", + $"수익률그래프_{periodLabel}", + "수익률 그래프", + "chart", + LegacyIndustryActionKind.Yield, + "s5086", + "5086", + yieldPeriod: period)); + } + + private static void AddCandle( + ICollection actions, + IndustryMarket market, + string periodLabel, + string sceneAlias, + LegacyIndustryCandleMode mode) + { + var volume = mode == LegacyIndustryCandleMode.Volume; + actions.Add(Action( + market, + $"candle-{(volume ? "volume-" : string.Empty)}{periodLabel}", + $"캔들그래프{(volume ? "(거래량)" : string.Empty)}_{periodLabel}", + volume ? "캔들 그래프 · 거래량" : "캔들 그래프", + "chart", + LegacyIndustryActionKind.Candle, + "s8010", + sceneAlias, + candleMode: mode)); + } + + private static LegacyIndustryActionDefinition Action( + IndustryMarket market, + string actionId, + string label, + string section, + string category, + LegacyIndustryActionKind kind, + string builderKey, + string sceneAlias, + int pageSize = 0, + string? yieldPeriod = null, + LegacyIndustryCandleMode? candleMode = null) => new( + market, + actionId, + label, + section, + category, + kind, + builderKey, + sceneAlias, + pageSize, + yieldPeriod, + candleMode); + + private static IReadOnlyDictionary CreateLookup( + IReadOnlyList actions) => + new System.Collections.ObjectModel.ReadOnlyDictionary< + string, + LegacyIndustryActionDefinition>(actions.ToDictionary( + action => action.ActionId, + StringComparer.Ordinal)); + + private static void Validate( + IReadOnlyCollection actions, + IndustryMarket market) + { + if (actions.Count != ActionsPerMarket || + actions.Select(action => action.ActionId).Distinct(StringComparer.Ordinal).Count() != + ActionsPerMarket || + actions.Any(action => action.Market != market) || + actions.Any(action => action.PageSize is not (0 or 5)) || + actions.Any(action => + (action.Kind == LegacyIndustryActionKind.FiveRow) != (action.PageSize == 5)) || + actions.Any(action => action.Kind == LegacyIndustryActionKind.Yield && + string.IsNullOrEmpty(action.YieldPeriod)) || + actions.Any(action => action.Kind != LegacyIndustryActionKind.Yield && + action.YieldPeriod is not null) || + actions.Any(action => action.Kind == LegacyIndustryActionKind.Candle && + action.CandleMode is null) || + actions.Any(action => action.Kind != LegacyIndustryActionKind.Candle && + action.CandleMode is not null) || + actions.Any(action => !IsSafeActionId(action.ActionId)) || + actions.Any(action => !LegacySceneCatalog.FindByCutAlias(action.SceneAlias) + .Any(scene => + string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) && + (int)scene.PageSize == action.PageSize))) + { + throw new InvalidOperationException("The legacy industry action catalog is invalid."); + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs new file mode 100644 index 0000000..96aa1d1 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyIndustrySelectionWorkflow.cs @@ -0,0 +1,442 @@ +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public sealed record LegacyIndustrySelectionSnapshot( + long Revision, + IndustryMarket? Market, + IReadOnlyList Industries, + IReadOnlyList Actions, + int? SelectedIndex, + IndustrySelection? SelectedIndustry, + IndustrySelection? FirstIndustry, + IndustrySelection? SecondIndustry); + +public sealed record LegacyIndustrySceneSelectionDraft( + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string DataCode); + +/// +/// Safe, generic playlist fields produced entirely from the C# catalog and the +/// authoritative UC2 selection state. It intentionally has no caller-supplied row id. +/// +public sealed record LegacyIndustryPlaylistDraft( + string ActionId, + string BuilderKey, + string SceneAlias, + int PageSize, + string Title, + string Detail, + string Category, + IndustryMarket Market, + LegacyIndustrySceneSelectionDraft Selection, + string RuntimeAssetRelativePath, + string RuntimeAssetSha256, + bool IsEnabled, + int FadeDuration); + +public sealed class LegacyIndustryWorkflowException : Exception +{ + public LegacyIndustryWorkflowException(string message) : base(message) + { + } +} + +/// +/// C# authority for the legacy UC2 selector and the UC1 industry actions. +/// +public sealed class LegacyIndustrySelectionWorkflow +{ + private const int DefaultFadeDuration = 6; + + private static readonly IReadOnlyList NoIndustries = + Array.AsReadOnly(Array.Empty()); + + private static readonly IReadOnlyList NoActions = + Array.AsReadOnly(Array.Empty()); + + private readonly IIndustrySelectionService _selectionService; + private readonly TimeProvider _timeProvider; + private IReadOnlyList _industries = NoIndustries; + private IReadOnlyList _actions = NoActions; + private IndustryMarket? _market; + private int? _selectedIndex; + private IndustrySelection? _selectedIndustry; + private IndustrySelection? _firstIndustry; + private IndustrySelection? _secondIndustry; + private long _revision; + private long _loadGeneration; + + public LegacyIndustrySelectionWorkflow( + IIndustrySelectionService selectionService, + TimeProvider? timeProvider = null) + { + _selectionService = selectionService ?? + throw new ArgumentNullException(nameof(selectionService)); + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public LegacyIndustrySelectionSnapshot Current => CreateSnapshot(); + + public async Task LoadMarketAsync( + IndustryMarket market, + CancellationToken cancellationToken = default) + { + ValidateMarket(market); + var generation = Interlocked.Increment(ref _loadGeneration); + var serviceRows = await _selectionService.GetAsync(market, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // A slower previous request must never replace a newer market request. + if (generation != Volatile.Read(ref _loadGeneration)) + { + return CreateSnapshot(); + } + + var normalizedRows = NormalizeRows(serviceRows, market); + if (generation != Volatile.Read(ref _loadGeneration)) + { + return CreateSnapshot(); + } + + var changingMarket = _market != market; + var previousSelected = changingMarket ? null : _selectedIndustry; + var previousFirst = changingMarket ? null : _firstIndustry; + var previousSecond = changingMarket ? null : _secondIndustry; + + _market = market; + _industries = normalizedRows; + _actions = LegacyIndustryActionCatalog.GetActions(market); + _selectedIndustry = Reconcile(previousSelected, normalizedRows); + _selectedIndex = _selectedIndustry is null + ? null + : FindIdentityIndex(normalizedRows, _selectedIndustry); + _firstIndustry = Reconcile(previousFirst, normalizedRows); + _secondIndustry = Reconcile(previousSecond, normalizedRows); + Touch(); + return CreateSnapshot(); + } + + public LegacyIndustrySelectionSnapshot SelectIndustry(int index) + { + if (!TryGetIndustry(index, out var industry)) + { + return CreateSnapshot(); + } + + _selectedIndex = index; + _selectedIndustry = industry; + Touch(); + return CreateSnapshot(); + } + + public LegacyIndustrySelectionSnapshot DoubleClickIndustry(int index) + { + if (!TryGetIndustry(index, out var industry)) + { + return CreateSnapshot(); + } + + _selectedIndex = index; + _selectedIndustry = industry; + + // UC2.listbox_DoubleClick copied txt1 to txt2 before assigning the newly + // double-clicked item to txt1. + _secondIndustry = _firstIndustry; + _firstIndustry = industry; + Touch(); + return CreateSnapshot(); + } + + public LegacyIndustrySelectionSnapshot SwapIndustries() + { + if (_firstIndustry is null && _secondIndustry is null) + { + return CreateSnapshot(); + } + + (_firstIndustry, _secondIndustry) = (_secondIndustry, _firstIndustry); + Touch(); + return CreateSnapshot(); + } + + public LegacyIndustrySelectionSnapshot ClearSelections() + { + if (_selectedIndustry is null && _firstIndustry is null && _secondIndustry is null) + { + return CreateSnapshot(); + } + + _selectedIndex = null; + _selectedIndustry = null; + _firstIndustry = null; + _secondIndustry = null; + Touch(); + return CreateSnapshot(); + } + + public LegacyIndustryPlaylistDraft MaterializePlaylistDraft(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (_market is not { } market) + { + throw new LegacyIndustryWorkflowException("Select an industry market first."); + } + + if (!LegacyIndustryActionCatalog.TryGetAction(market, actionId, out var definition) || + definition is null) + { + throw new LegacyIndustryWorkflowException("The industry action is not registered."); + } + + var projection = ProjectSelection(definition, market); + var asset = LegacyIndustryActionCatalog.GetRuntimeAsset(market); + return new LegacyIndustryPlaylistDraft( + definition.ActionId, + definition.BuilderKey, + definition.SceneAlias, + definition.PageSize, + projection.Title, + definition.Label, + definition.Category, + market, + projection.Selection, + asset.RelativePath, + asset.Sha256, + IsEnabled: true, + FadeDuration: DefaultFadeDuration); + } + + private IndustryProjection ProjectSelection( + LegacyIndustryActionDefinition definition, + IndustryMarket market) + { + var industryGroup = market == IndustryMarket.Kospi + ? "INDUSTRY_KOSPI" + : "INDUSTRY_KOSDAQ"; + var marketName = market == IndustryMarket.Kospi ? "KOSPI" : "KOSDAQ"; + + return definition.Kind switch + { + LegacyIndustryActionKind.Single => ForSelected( + definition, + industryGroup, + "1열판", + "CURRENT"), + LegacyIndustryActionKind.Pair => ForPair(definition, industryGroup), + LegacyIndustryActionKind.Yield => ForSelected( + definition, + industryGroup, + "YIELD_GRAPH", + definition.YieldPeriod!), + LegacyIndustryActionKind.Candle => ForSelected( + definition, + market == IndustryMarket.Kospi ? "KOSPI_INDUSTRY" : "KOSDAQ_INDUSTRY", + string.Empty, + definition.CandleMode == LegacyIndustryCandleMode.Volume ? "VOLUME" : "PRICE"), + LegacyIndustryActionKind.FiveRow => new IndustryProjection( + $"{MarketLabel(market)} {definition.Label}", + new LegacyIndustrySceneSelectionDraft( + market == IndustryMarket.Kospi + ? "PAGED_DOMESTIC_KOSPI" + : "PAGED_DOMESTIC_KOSDAQ", + "INDEX", + string.Empty, + "INDUSTRY", + CurrentLocalDate())), + LegacyIndustryActionKind.Square => new IndustryProjection( + $"{MarketLabel(market)} {definition.Label}", + new LegacyIndustrySceneSelectionDraft( + industryGroup, + marketName, + "네모그래프", + string.Empty, + string.Empty)), + LegacyIndustryActionKind.Sector => new IndustryProjection( + $"{MarketLabel(market)} {definition.Label}", + new LegacyIndustrySceneSelectionDraft( + industryGroup, + marketName, + "SECTOR_INDEX", + string.Empty, + string.Empty)), + _ => throw new LegacyIndustryWorkflowException("The industry action kind is unsupported.") + }; + } + + private IndustryProjection ForSelected( + LegacyIndustryActionDefinition definition, + string groupCode, + string graphicType, + string subtype) + { + if (_selectedIndustry is not { } selected) + { + throw new LegacyIndustryWorkflowException( + $"The {definition.ActionId} action requires one selected industry."); + } + + return new IndustryProjection( + selected.IndustryName, + new LegacyIndustrySceneSelectionDraft( + groupCode, + selected.IndustryName, + graphicType, + subtype, + string.Empty)); + } + + private IndustryProjection ForPair( + LegacyIndustryActionDefinition definition, + string groupCode) + { + if (_firstIndustry is not { } first || _secondIndustry is not { } second || + first.IndustryName.Contains('-', StringComparison.Ordinal) || + second.IndustryName.Contains('-', StringComparison.Ordinal)) + { + throw new LegacyIndustryWorkflowException( + $"The {definition.ActionId} action requires two delimiter-safe industries."); + } + + var subject = $"{first.IndustryName}-{second.IndustryName}"; + return new IndustryProjection( + subject, + new LegacyIndustrySceneSelectionDraft( + groupCode, + subject, + "2열판", + string.Empty, + string.Empty)); + } + + private string CurrentLocalDate() + { + var local = _timeProvider.GetLocalNow(); + return local.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + } + + private bool TryGetIndustry(int index, out IndustrySelection industry) + { + if (index < 0 || index >= _industries.Count) + { + industry = null!; + return false; + } + + industry = _industries[index]; + return true; + } + + private static IReadOnlyList NormalizeRows( + IReadOnlyList? rows, + IndustryMarket market) + { + if (rows is null || rows.Count > LegacyIndustrySelectionService.MaximumResults) + { + throw new LegacyIndustryWorkflowException("The industry list is invalid."); + } + + var names = new HashSet(StringComparer.Ordinal); + var codes = new HashSet(StringComparer.Ordinal); + var normalized = new IndustrySelection[rows.Count]; + for (var index = 0; index < rows.Count; index++) + { + var row = rows[index]; + if (row is null || row.Market != market) + { + throw new LegacyIndustryWorkflowException( + "The industry list contains a cross-market row."); + } + + var name = row.IndustryName?.Trim(); + var code = row.IndustryCode?.Trim(); + if (string.IsNullOrEmpty(name) || + name.Length > LegacyIndustrySelectionService.MaximumNameLength || + name.Any(char.IsControl) || + string.IsNullOrEmpty(code) || + code.Length > 32 || + code.Any(character => !char.IsAsciiLetterOrDigit(character)) || + !names.Add(name) || + !codes.Add(code)) + { + throw new LegacyIndustryWorkflowException( + "The industry list contains an unsafe or ambiguous row."); + } + + normalized[index] = new IndustrySelection(market, name, code); + } + + return Array.AsReadOnly(normalized); + } + + private static IndustrySelection? Reconcile( + IndustrySelection? previous, + IReadOnlyList rows) + { + if (previous is null) + { + return null; + } + + return rows.FirstOrDefault(row => SameIdentity(row, previous)); + } + + private static int? FindIdentityIndex( + IReadOnlyList rows, + IndustrySelection selected) + { + for (var index = 0; index < rows.Count; index++) + { + if (SameIdentity(rows[index], selected)) + { + return index; + } + } + + return null; + } + + private static bool SameIdentity(IndustrySelection left, IndustrySelection right) => + left.Market == right.Market && + string.Equals(left.IndustryName, right.IndustryName, StringComparison.Ordinal) && + string.Equals(left.IndustryCode, right.IndustryCode, StringComparison.Ordinal); + + private static void ValidateMarket(IndustryMarket market) + { + if (market is not (IndustryMarket.Kospi or IndustryMarket.Kosdaq)) + { + throw new ArgumentOutOfRangeException( + nameof(market), + market, + "Unsupported industry market."); + } + } + + private static string MarketLabel(IndustryMarket market) => market switch + { + IndustryMarket.Kospi => "코스피", + IndustryMarket.Kosdaq => "코스닥", + _ => throw new ArgumentOutOfRangeException(nameof(market), market, null) + }; + + private LegacyIndustrySelectionSnapshot CreateSnapshot() => new( + _revision, + _market, + _industries, + _actions, + _selectedIndex, + _selectedIndustry, + _firstIndustry, + _secondIndustry); + + private void Touch() => _revision++; + + private sealed record IndustryProjection( + string Title, + LegacyIndustrySceneSelectionDraft Selection); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs index d071668..b52fe59 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorController.cs @@ -1,3 +1,6 @@ +using S5025ManualAudience = + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience; + namespace MBN_STOCK_WEBVIEW.LegacyApplication; public enum LegacySearchTrigger @@ -14,6 +17,26 @@ public enum LegacyOperatorStatusKind Error } +public enum LegacyPlaylistMoveDirection +{ + Up, + Down +} + +public enum LegacyOperatorTabId +{ + Overseas, + Exchange, + Index, + KospiIndustry, + KosdaqIndustry, + Comparison, + Theme, + OverseasStocks, + Expert, + TradingHalt +} + public sealed record LegacyStockResultRow(int Index, string DisplayName); public sealed record LegacyCutViewRow( @@ -25,6 +48,47 @@ public sealed record LegacyCutViewRow( public sealed record LegacyDialogState(string Message); +public sealed record LegacyOperatorCommandResult( + long Sequence, + string Command, + string TargetId, + bool Succeeded); + +public sealed record LegacyOperatorTabState( + LegacyOperatorTabId Id, + string Label, + bool IsActive); + +public sealed record LegacyFixedActionView( + string Id, + string Label, + string Detail, + bool IsAvailable, + string Prerequisite); + +public sealed record LegacyFixedSectionView( + int Index, + string Name, + IReadOnlyList Actions); + +public sealed record LegacyFixedCatalogView( + LegacyFixedMarket Market, + IReadOnlyList Sections); + +/// +/// A fixed UC1 parent-node activation is staged until every modal manual child has +/// been confirmed. The playlist remains unchanged while this view is present. +/// +public sealed record LegacyFixedSectionBatchView( + int SectionIndex, + string SectionName, + int TotalActionCount, + int StagedActionCount, + int ManualStepNumber, + int ManualStepCount, + string PendingActionId, + string PendingActionLabel); + public sealed record LegacyOperatorSnapshot( long Revision, string SearchText, @@ -34,7 +98,22 @@ public sealed record LegacyOperatorSnapshot( IReadOnlyList Playlist, LegacyDialogState? Dialog, string StatusMessage, - LegacyOperatorStatusKind StatusKind); + LegacyOperatorStatusKind StatusKind, + IReadOnlyList? Tabs = null, + LegacyMovingAverageSelection MovingAverages = default, + LegacyFixedCatalogView? FixedCatalog = null, + LegacyIndustrySelectionSnapshot? Industry = null, + LegacyThemeWorkflowSnapshot? Theme = null, + LegacyExpertWorkflowSnapshot? Expert = null, + LegacyTradingHaltWorkflowSnapshot? TradingHalt = null, + LegacyComparisonWorkflowSnapshot? Comparison = null, + LegacyManualFinancialWorkflowSnapshot? ManualFinancial = null, + LegacyNamedPlaylistWorkflowSnapshot? NamedPlaylist = null, + LegacyOverseasWorkflowSnapshot? Overseas = null, + LegacyManualListsSnapshot? ManualLists = null, + LegacyOperatorCatalogWorkflowSnapshot? OperatorCatalog = null, + LegacyOperatorCommandResult? CommandResult = null, + LegacyFixedSectionBatchView? FixedSectionBatch = null); /// /// Authoritative C# state for the first legacy operator vertical slice. The WebView sends @@ -45,9 +124,36 @@ public sealed class LegacyOperatorController private const long DoubleClickWindowMilliseconds = 500; private readonly ILegacyStockLookup _stockLookup; + private readonly LegacyFixedActionCatalog _fixedCatalog; + private readonly TimeProvider _timeProvider; + private readonly LegacyIndustrySelectionWorkflow? _industryWorkflow; + private readonly LegacyThemeWorkflow? _themeWorkflow; + private readonly LegacyExpertWorkflowController? _expertWorkflow; + private readonly LegacyTradingHaltWorkflowController? _tradingHaltWorkflow; + private readonly LegacyComparisonWorkflowController? _comparisonWorkflow; + private readonly LegacyManualFinancialWorkflow? _manualFinancialWorkflow; + private readonly LegacyNamedPlaylistWorkflowController? _namedPlaylistWorkflow; + private readonly LegacyOverseasSelectionWorkflow? _overseasWorkflow; + private readonly LegacyManualListsWorkflow? _manualListsWorkflow; + private readonly LegacyOperatorCatalogWorkflowController? _operatorCatalogWorkflow; + private readonly SemaphoreSlim _tabActivationGate = new(1, 1); private readonly SortedSet _legacyPointerRows = []; private readonly SortedSet _nativeSelectedCutRows = []; private readonly List _playlist = []; + private readonly HashSet _selectedPlaylistRowIds = new(StringComparer.Ordinal); + private readonly List _tabOrder = + [ + LegacyOperatorTabId.Overseas, + LegacyOperatorTabId.Exchange, + LegacyOperatorTabId.Index, + LegacyOperatorTabId.KospiIndustry, + LegacyOperatorTabId.KosdaqIndustry, + LegacyOperatorTabId.Comparison, + LegacyOperatorTabId.Theme, + LegacyOperatorTabId.OverseasStocks, + LegacyOperatorTabId.Expert, + LegacyOperatorTabId.TradingHalt + ]; private Queue? _pendingDropCutRows; private LegacyStockSearchData _searchData = new(string.Empty, [], []); private int? _selectedStockIndex; @@ -57,20 +163,93 @@ public sealed class LegacyOperatorController private int _previousPointerX; private int _previousPointerY; private long? _previousPointerTimestamp; + private string? _activePlaylistRowId; + private int _playlistCursorIndex; + private int _playlistPriorCursorIndex; private long _revision; private long _playlistSequence; private string _searchText = string.Empty; private string _statusMessage = string.Empty; private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; private LegacyDialogState? _dialog; + private LegacyOperatorTabId _activeTab = LegacyOperatorTabId.Overseas; + private LegacyMovingAverageSelection _movingAverages = new(true, true); + private LegacyThemeWorkflowSnapshot? _themeSnapshot; + private LegacyManualFinancialWorkflowSnapshot? _manualFinancialSnapshot; + private bool _comparisonLoaded; + private bool _themeActivationListLoaded; + private bool _expertActivationListLoaded; + private long _tabActivationRequest; + private long _commandSequence; + private LegacyOperatorCommandResult? _commandResult; + private PendingFixedSectionBatch? _pendingFixedSectionBatch; - public LegacyOperatorController(ILegacyStockLookup stockLookup) + public LegacyOperatorController( + ILegacyStockLookup stockLookup, + LegacyFixedActionCatalog? fixedCatalog = null, + TimeProvider? timeProvider = null, + LegacyIndustrySelectionWorkflow? industryWorkflow = null, + LegacyThemeWorkflow? themeWorkflow = null, + LegacyExpertWorkflowController? expertWorkflow = null, + LegacyTradingHaltWorkflowController? tradingHaltWorkflow = null, + LegacyComparisonWorkflowController? comparisonWorkflow = null, + LegacyManualFinancialWorkflow? manualFinancialWorkflow = null, + LegacyNamedPlaylistWorkflowController? namedPlaylistWorkflow = null, + LegacyOverseasSelectionWorkflow? overseasWorkflow = null, + LegacyManualListsWorkflow? manualListsWorkflow = null, + LegacyOperatorCatalogWorkflowController? operatorCatalogWorkflow = null) { _stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup)); + _fixedCatalog = fixedCatalog ?? LegacyFixedActionCatalog.Default; + _timeProvider = timeProvider ?? TimeProvider.System; + _industryWorkflow = industryWorkflow; + _themeWorkflow = themeWorkflow; + _themeSnapshot = themeWorkflow?.CreateInitial(); + _expertWorkflow = expertWorkflow; + _tradingHaltWorkflow = tradingHaltWorkflow; + _comparisonWorkflow = comparisonWorkflow; + _manualFinancialWorkflow = manualFinancialWorkflow; + _namedPlaylistWorkflow = namedPlaylistWorkflow; + _overseasWorkflow = overseasWorkflow; + _manualListsWorkflow = manualListsWorkflow; + _operatorCatalogWorkflow = operatorCatalogWorkflow; + _tradingHaltWorkflow?.SetMovingAverages( + _movingAverages.Ma5, + _movingAverages.Ma20); + _overseasWorkflow?.SetMovingAverages( + _movingAverages.Ma5, + _movingAverages.Ma20); } public LegacyOperatorSnapshot Current => CreateSnapshot(); + public LegacyOperatorPlayoutRequest CreatePlayoutRequest() + { + if (_playlist.Count == 0) + { + throw new InvalidOperationException("플레이리스트에 송출할 항목이 없습니다."); + } + + var selectedIndex = _activePlaylistRowId is null + ? -1 + : FindPlaylistIndex(_activePlaylistRowId); + if (selectedIndex < 0) + { + selectedIndex = _playlist.FindIndex(row => + _selectedPlaylistRowIds.Contains(row.RowId)); + } + + if (selectedIndex < 0) + { + selectedIndex = Math.Clamp(_playlistCursorIndex, 0, _playlist.Count - 1); + } + + var entries = _playlist.Select(static row => row.ToPlayoutEntry()).ToArray(); + return new LegacyOperatorPlayoutRequest( + Array.AsReadOnly(entries), + selectedIndex); + } + public async Task SearchStocksAsync( string text, LegacySearchTrigger trigger, @@ -181,6 +360,2710 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot TogglePlaylistRowEnabled(string rowId) + { + ArgumentNullException.ThrowIfNull(rowId); + var index = FindPlaylistIndex(rowId); + if (index >= 0) + { + _playlist[index] = _playlist[index] with + { + IsEnabled = !_playlist[index].IsEnabled + }; + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SetAllPlaylistRowsEnabled(bool enabled) + { + if (_playlist.Count == 0) + { + return CreateSnapshot(); + } + + for (var index = 0; index < _playlist.Count; index++) + { + _playlist[index] = _playlist[index] with { IsEnabled = enabled }; + } + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectTab(LegacyOperatorTabId tabId) + { + if (_pendingFixedSectionBatch is not null && _activeTab != tabId) + { + return ReportError( + "고정 컷 섹션의 수동 입력은 원본 모달 창과 같이 완료하거나 취소하기 전까지 탭을 바꿀 수 없습니다."); + } + + if (!_tabOrder.Contains(tabId) || _activeTab == tabId) + { + return CreateSnapshot(); + } + + BeginTabActivation(tabId); + _activeTab = tabId; + Interlocked.Increment(ref _tabActivationRequest); + Touch(); + return CreateSnapshot(); + } + + public async Task SelectTabAsync( + LegacyOperatorTabId tabId, + CancellationToken cancellationToken = default) + { + var changed = _activeTab != tabId; + SelectTab(tabId); + var activationRequest = Volatile.Read(ref _tabActivationRequest); + await _tabActivationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (!IsCurrentTabActivation(tabId, activationRequest)) + { + return CreateSnapshot(); + } + + var market = GetActiveIndustryMarket(); + var touched = false; + if (_industryWorkflow is not null && market is not null && + (changed || _industryWorkflow.Current.Market != market.Value)) + { + await _industryWorkflow.LoadMarketAsync(market.Value, cancellationToken) + .ConfigureAwait(false); + if (!IsCurrentTabActivation(tabId, activationRequest)) + { + return CreateSnapshot(); + } + + touched = true; + } + + if (IsComparisonActive() && _comparisonWorkflow is not null && !_comparisonLoaded) + { + await _comparisonWorkflow.LoadSavedPairsAsync(cancellationToken) + .ConfigureAwait(false); + if (!IsCurrentTabActivation(tabId, activationRequest)) + { + return CreateSnapshot(); + } + + _comparisonLoaded = true; + touched = true; + } + + // Legacy UC4_Load starts a one-shot timer whose first tick calls + // GetThemeByKeyword with the initial empty query. Keep that load in + // native authority so Web code cannot decide when or what to query. + if (IsThemeActive() && _themeWorkflow is not null && + _themeSnapshot is not null && !_themeActivationListLoaded) + { + var loaded = await _themeWorkflow.SearchAsync( + _themeSnapshot, + string.Empty, + MMoneyCoderSharp.Data.ThemeSession.PreMarket, + cancellationToken).ConfigureAwait(false); + if (!IsCurrentTabActivation(tabId, activationRequest)) + { + return CreateSnapshot(); + } + + _themeSnapshot = loaded; + _themeActivationListLoaded = true; + touched = true; + } + + // Legacy UC6_Load calls Data_Load immediately, which loads the full + // EXPERT_LIST. An empty native search is its parameterized equivalent. + if (IsExpertActive() && _expertWorkflow is not null && + !_expertActivationListLoaded) + { + await _expertWorkflow.SearchAsync(string.Empty, cancellationToken) + .ConfigureAwait(false); + if (!IsCurrentTabActivation(tabId, activationRequest)) + { + return CreateSnapshot(); + } + + _expertActivationListLoaded = true; + touched = true; + } + + if (touched) + { + Touch(); + } + + return CreateSnapshot(); + } + finally + { + _tabActivationGate.Release(); + } + } + + public LegacyOperatorSnapshot SwapTabs( + LegacyOperatorTabId source, + LegacyOperatorTabId target) + { + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "고정 컷 섹션의 수동 입력을 완료하거나 취소하기 전에는 탭 순서를 바꿀 수 없습니다."); + } + + var sourceIndex = _tabOrder.IndexOf(source); + var targetIndex = _tabOrder.IndexOf(target); + if (sourceIndex < 0 || targetIndex < 0 || sourceIndex == targetIndex) + { + return CreateSnapshot(); + } + + (_tabOrder[sourceIndex], _tabOrder[targetIndex]) = + (_tabOrder[targetIndex], _tabOrder[sourceIndex]); + if (_activeTab != source) + { + Interlocked.Increment(ref _tabActivationRequest); + BeginTabActivation(source); + } + + _activeTab = source; + Touch(); + return CreateSnapshot(); + } + + public async Task SwapTabsAsync( + LegacyOperatorTabId source, + LegacyOperatorTabId target, + CancellationToken cancellationToken = default) + { + if (!_tabOrder.Contains(source) || !_tabOrder.Contains(target) || source == target) + { + return SwapTabs(source, target); + } + + SwapTabs(source, target); + return await SelectTabAsync(source, cancellationToken).ConfigureAwait(false); + } + + public LegacyOperatorSnapshot SetMovingAverages(bool ma5, bool ma20) + { + var next = new LegacyMovingAverageSelection(ma5, ma20); + if (_movingAverages == next) + { + return CreateSnapshot(); + } + + _movingAverages = next; + _tradingHaltWorkflow?.SetMovingAverages(ma5, ma20); + _overseasWorkflow?.SetMovingAverages(ma5, ma20); + Touch(); + return CreateSnapshot(); + } + + public async Task OpenOperatorCatalogAsync( + LegacyOperatorCatalogEntity entity, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.OpenAsync(entity, cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task BeginThemeCatalogFromUc4Async( + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.OpenAsync( + LegacyOperatorCatalogEntity.Theme, + cancellationToken).ConfigureAwait(false); + if (_operatorCatalogWorkflow.Current.CanBeginCreate) + { + await _operatorCatalogWorkflow.BeginCreateThemeAsync( + MMoneyCoderSharp.Data.ThemeMarket.Krx, + cancellationToken).ConfigureAwait(false); + } + + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task EditSelectedThemeCatalogAsync( + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA 관리 기능이 구성되지 않았습니다."); + } + + var identity = _themeSnapshot?.SelectedIdentity; + if (identity is null) + { + return ReportError("편집할 테마를 먼저 선택하세요."); + } + + await _operatorCatalogWorkflow.OpenThemeForEditAsync(identity, cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task DeleteSelectedThemeCatalogAsync( + CancellationToken cancellationToken = default) + { + await EditSelectedThemeCatalogAsync(cancellationToken).ConfigureAwait(false); + if (_operatorCatalogWorkflow?.Current.CanDelete == true) + { + await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + } + + return CreateSnapshot(); + } + + public async Task BeginExpertCatalogFromUc6Async( + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.OpenAsync( + LegacyOperatorCatalogEntity.Expert, + cancellationToken).ConfigureAwait(false); + if (_operatorCatalogWorkflow.Current.CanBeginCreate) + { + await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken) + .ConfigureAwait(false); + } + + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task EditSelectedExpertCatalogAsync( + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("EList 관리 기능이 구성되지 않았습니다."); + } + + var selected = _expertWorkflow?.Current.SelectedExpert; + if (selected is null) + { + return ReportError("편집할 전문가를 먼저 선택하세요."); + } + + await _operatorCatalogWorkflow.OpenExpertForEditAsync( + new MMoneyCoderSharp.Data.ExpertSelectionIdentity( + selected.ExpertCode, + selected.ExpertName), + cancellationToken).ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task DeleteSelectedExpertCatalogAsync( + CancellationToken cancellationToken = default) + { + await EditSelectedExpertCatalogAsync(cancellationToken).ConfigureAwait(false); + if (_operatorCatalogWorkflow?.Current.CanDelete == true) + { + await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot CloseOperatorCatalog() + { + if (_operatorCatalogWorkflow is null) + { + return CreateSnapshot(); + } + + _operatorCatalogWorkflow.Close(); + Touch(); + return CreateSnapshot(); + } + + public async Task SearchOperatorCatalogAsync( + string query, + MMoneyCoderSharp.Data.ThemeSession nxtSession, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.SearchAsync(query, nxtSession, cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task SelectOperatorCatalogResultAsync( + string resultId, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.SelectAsync(resultId, cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task BeginCreateThemeCatalogAsync( + MMoneyCoderSharp.Data.ThemeMarket market, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.BeginCreateThemeAsync(market, cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task BeginCreateExpertCatalogAsync( + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.BeginCreateExpertAsync(cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task SearchOperatorCatalogStocksAsync( + string query, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.SearchStocksAsync(query, cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectOperatorCatalogStock(string resultId) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.SelectStock(resultId); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot AddOperatorCatalogStock(decimal? buyAmount) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.AddSelectedStock(buyAmount); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot MoveOperatorCatalogDraftRow( + string rowId, + LegacyOperatorCatalogMoveDirection direction) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.MoveDraftRow(rowId, direction); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SetOperatorCatalogDraftBuyAmount( + string rowId, + decimal buyAmount) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.SetDraftBuyAmount(rowId, buyAmount); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ActivateOperatorCatalogStock( + string resultId, + decimal? buyAmount) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.SelectStock(resultId); + if (string.Equals( + _operatorCatalogWorkflow.Current.SelectedStockResultId, + resultId, + StringComparison.Ordinal)) + { + _operatorCatalogWorkflow.AddSelectedStock(buyAmount); + } + + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot RemoveOperatorCatalogDraftRow(string rowId) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + _operatorCatalogWorkflow.RemoveDraftRow(rowId); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task SaveOperatorCatalogAsync( + string editorName, + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.SaveAsync(editorName, cancellationToken) + .ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task DeleteOperatorCatalogAsync( + CancellationToken cancellationToken = default) + { + if (_operatorCatalogWorkflow is null) + { + return ReportError("ThemeA/EList 관리 기능이 구성되지 않았습니다."); + } + + await _operatorCatalogWorkflow.DeleteAsync(cancellationToken).ConfigureAwait(false); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot InvalidateOperatorCatalogBrowserCorrelation() + { + if (_operatorCatalogWorkflow is null) + { + return CreateSnapshot(); + } + + _operatorCatalogWorkflow.InvalidateBrowserCorrelation(); + ApplyOperatorCatalogStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task OpenManualFinancialAsync( + MMoneyCoderSharp.Data.ManualFinancialScreenKind screen, + CancellationToken cancellationToken = default) + { + if (_manualFinancialWorkflow is null) + { + return ReportError("수동 재무 입력 기능이 구성되지 않았습니다."); + } + + _manualFinancialSnapshot = _manualFinancialWorkflow.CreateInitial(screen); + _manualFinancialSnapshot = await _manualFinancialWorkflow.SearchAsync( + _manualFinancialSnapshot, + string.Empty, + cancellationToken).ConfigureAwait(false); + _statusMessage = $"{_manualFinancialSnapshot.Definition.Label} 목록을 불러왔습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot CloseManualFinancial() + { + if (_manualFinancialSnapshot is null) + { + return CreateSnapshot(); + } + + _manualFinancialSnapshot = null; + Touch(); + return CreateSnapshot(); + } + + public async Task SearchManualFinancialAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + _manualFinancialSnapshot = await workflow.SearchAsync( + snapshot, + query, + cancellationToken).ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ToggleManualFinancialNameSort(long expectedRevision) + { + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("Open a manual-financial screen before sorting its list."); + } + + try + { + _manualFinancialSnapshot = workflow.ToggleNameSort( + snapshot, + expectedRevision); + _statusMessage = "The GraphE stock-name order was changed."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (Exception exception) when (exception is ArgumentException or + InvalidOperationException) + { + _dialog = new LegacyDialogState(exception.Message); + _statusMessage = exception.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } + } + + public async Task FindManualFinancialAsync( + string query, + LegacyManualFinancialFindDirection direction, + long expectedRevision, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("Open a manual-financial screen before finding a stock."); + } + + try + { + _manualFinancialSnapshot = workflow.FindByName( + snapshot, + query, + direction, + expectedRevision); + if (_manualFinancialSnapshot.LastMessage is not null) + { + _statusMessage = _manualFinancialSnapshot.LastMessage; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } + + return await LoadManualFinancialAsync( + _manualFinancialSnapshot.SelectedRowId!, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is ArgumentException or + InvalidOperationException or KeyNotFoundException or + LegacyManualFinancialWorkflowDataException) + { + // A stale browser generation or a failed fresh detail read leaves the + // GraphE modal open. The operator may inspect the failure; no read is retried. + _dialog = new LegacyDialogState(exception.Message); + _statusMessage = exception.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + return CreateSnapshot(); + } + } + + public async Task LoadManualFinancialAsync( + string resultId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resultId); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + _manualFinancialSnapshot = await workflow.LoadAsync( + snapshot, + resultId, + cancellationToken).ConfigureAwait(false); + + // GraphE identities contain STOCK_NAME only. Resolve the exact stock master + // immediately after the fresh detail read when it is unambiguous; otherwise + // leave the candidates visible so the operator can resolve them explicitly. + var stockName = _manualFinancialSnapshot.SelectedRecord!.Identity.StockName; + _manualFinancialSnapshot = await workflow.SearchStocksAsync( + _manualFinancialSnapshot, + stockName, + cancellationToken).ConfigureAwait(false); + var exact = _manualFinancialSnapshot.StockCandidates + .Where(row => string.Equals(row.Name, stockName, StringComparison.Ordinal)) + .ToArray(); + if (exact.Length == 1) + { + try + { + _manualFinancialSnapshot = workflow.SelectStock( + _manualFinancialSnapshot, + exact[0].ResultId); + } + catch (ArgumentException) + { + // Ambiguous provider identities remain unverified and visible. + } + } + + Touch(); + return CreateSnapshot(); + } + + public async Task ActivateManualFinancialResultAsync( + string resultId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resultId); + const string command = "activate-manual-financial-result"; + if (!TryGetManualFinancial(out _, out _)) + { + CompleteCommand(command, resultId, succeeded: false); + return ReportError("Open a manual-financial screen before activating a result."); + } + + try + { + // A GraphE list identity is only an opaque handle. Re-read and validate the + // exact record and stock-master identity before deriving the playlist row. + await LoadManualFinancialAsync(resultId, cancellationToken).ConfigureAwait(false); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + throw new InvalidOperationException( + "The manual-financial result is no longer available."); + } + + var draft = workflow.MaterializePlaylistDraft( + snapshot, + snapshot.Definition.ActionId); + AppendManualFinancialRow(draft); + _manualFinancialSnapshot = null; + _statusMessage = $"{draft.Title} {draft.Detail} cut was added."; + _statusKind = LegacyOperatorStatusKind.Information; + CompleteCommand(command, resultId, succeeded: true); + Touch(); + return CreateSnapshot(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is ArgumentException or + InvalidOperationException or KeyNotFoundException or + LegacyManualFinancialWorkflowDataException) + { + // Keep the GraphE modal open and never append a partial row. + _dialog = new LegacyDialogState(exception.Message); + _statusMessage = exception.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + CompleteCommand(command, resultId, succeeded: false); + Touch(); + return CreateSnapshot(); + } + } + + public LegacyOperatorSnapshot BeginManualFinancialCreate() + { + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + _manualFinancialSnapshot = workflow.BeginCreate(snapshot); + Touch(); + return CreateSnapshot(); + } + + public async Task SearchManualFinancialStocksAsync( + string stockName, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(stockName); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + _manualFinancialSnapshot = await workflow.SearchStocksAsync( + snapshot, + stockName, + cancellationToken).ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectManualFinancialStock(string resultId) + { + ArgumentNullException.ThrowIfNull(resultId); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + try + { + _manualFinancialSnapshot = workflow.SelectStock(snapshot, resultId); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + public async Task SaveManualFinancialAsync( + MMoneyCoderSharp.Data.ManualFinancialRecord record, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(record); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + try + { + LegacyManualFinancialWriteRequest request = snapshot.SelectedRecord is null + ? workflow.CreateCreateRequest(snapshot, record) + : workflow.CreateUpdateRequest(snapshot, record); + _manualFinancialSnapshot = await workflow.ExecuteAsync( + snapshot, + request, + cancellationToken).ConfigureAwait(false); + ApplyManualFinancialResultStatus(_manualFinancialSnapshot, "저장"); + Touch(); + return CreateSnapshot(); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + public async Task DeleteManualFinancialAsync( + bool all, + CancellationToken cancellationToken = default) + { + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + try + { + LegacyManualFinancialWriteRequest request = all + ? workflow.CreateDeleteAllRequest( + snapshot, + snapshot.Definition.DeleteAllConfirmationToken) + : workflow.CreateDeleteRequest(snapshot); + _manualFinancialSnapshot = await workflow.ExecuteAsync( + snapshot, + request, + cancellationToken).ConfigureAwait(false); + ApplyManualFinancialResultStatus( + _manualFinancialSnapshot, + all ? "전체 삭제" : "삭제"); + Touch(); + return CreateSnapshot(); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + public LegacyOperatorSnapshot ActivateManualFinancialAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!TryGetManualFinancial(out var workflow, out var snapshot)) + { + return ReportError("수동 재무 입력 화면을 먼저 여세요."); + } + + try + { + var draft = workflow.MaterializePlaylistDraft(snapshot, actionId); + AppendManualFinancialRow(draft); + _statusMessage = $"{draft.Title} {draft.Detail} 컷을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + public async Task OpenManualNetSellAsync( + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience, + CancellationToken cancellationToken = default) + { + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요."); + } + + return await OpenManualNetSellCoreAsync(audience, cancellationToken) + .ConfigureAwait(false); + } + + private async Task OpenManualNetSellCoreAsync( + MBN_STOCK_WEBVIEW.Core.Playout.Scenes.S5025ManualAudience audience, + CancellationToken cancellationToken) + { + if (_manualListsWorkflow is null) + { + return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다."); + } + + await _manualListsWorkflow.OpenNetSellAsync(audience, cancellationToken) + .ConfigureAwait(false); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task OpenManualViAsync( + CancellationToken cancellationToken = default) + { + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "고정 컷 섹션이 요청한 순서대로 현재 수동 입력을 완료하거나 취소하세요."); + } + + return await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false); + } + + private async Task OpenManualViCoreAsync( + CancellationToken cancellationToken) + { + if (_manualListsWorkflow is null) + { + return ReportError("VI 수동 목록 기능을 사용할 수 없습니다."); + } + + await _manualListsWorkflow.OpenViAsync(cancellationToken).ConfigureAwait(false); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot CloseManualLists() + { + _manualListsWorkflow?.Close(); + if (_pendingFixedSectionBatch is { } pending) + { + _pendingFixedSectionBatch = null; + _statusMessage = + $"{pending.SectionName} 섹션 추가를 취소했습니다. 편성표에는 아무 항목도 추가하지 않았습니다. 이미 확인한 수동 저장 내용은 유지됩니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + } + + Touch(); + return CreateSnapshot(); + } + + public async Task RefreshManualListsAsync( + CancellationToken cancellationToken = default) + { + if (_manualListsWorkflow is null) + { + return ReportError("수동 목록 기능을 사용할 수 없습니다."); + } + + await _manualListsWorkflow.RefreshAsync(cancellationToken).ConfigureAwait(false); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SetManualNetSellCell( + string rowId, + LegacyManualNetSellField field, + string value) + { + if (_manualListsWorkflow is null) + { + return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다."); + } + + _manualListsWorkflow.SetNetSellCell(rowId, field, value); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task SaveManualNetSellAsync( + CancellationToken cancellationToken = default) + { + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다."); + } + + if (_manualListsWorkflow is null) + { + return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다."); + } + + await _manualListsWorkflow.SaveNetSellAsync(cancellationToken).ConfigureAwait(false); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot AddManualNetSellPlaylistRow() + { + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다."); + } + + if (_manualListsWorkflow is null) + { + return ReportError("수동 순매도 입력 기능을 사용할 수 없습니다."); + } + + try + { + AppendManualListRow(_manualListsWorkflow.CreateNetSellPlaylistDraft()); + _statusMessage = "검증된 수동 순매도 컷을 편성표에 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + } + + Touch(); + return CreateSnapshot(); + } + + public async Task SearchManualViAsync( + string query, + CancellationToken cancellationToken = default) + { + if (_manualListsWorkflow is null) + { + return ReportError("VI 수동 목록 기능을 사용할 수 없습니다."); + } + + await _manualListsWorkflow.SearchViAsync(query, cancellationToken) + .ConfigureAwait(false); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectManualViSearchResult(string resultId) + { + if (_manualListsWorkflow is null) + { + return ReportError("The manual VI list feature is unavailable."); + } + + _manualListsWorkflow.SelectViSearchResult(resultId); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot AddManualViSearchResult(string resultId) + { + if (_manualListsWorkflow is null) + { + return ReportError("VI 수동 목록 기능을 사용할 수 없습니다."); + } + + _manualListsWorkflow.AddViSearchResult(resultId); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot MoveManualViItem( + string itemId, + LegacyManualListMoveDirection direction) + { + _manualListsWorkflow?.MoveViItem(itemId, direction); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot DeleteManualViItem(string itemId) + { + _manualListsWorkflow?.DeleteViItem(itemId); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot DeleteAllManualViItems() + { + _manualListsWorkflow?.DeleteAllViItems(); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public async Task SaveManualViAsync( + CancellationToken cancellationToken = default) + { + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "섹션 수동 입력은 확인 명령으로만 저장하고 다음 단계로 진행할 수 있습니다."); + } + + if (_manualListsWorkflow is null) + { + return ReportError("VI 수동 목록 기능을 사용할 수 없습니다."); + } + + await _manualListsWorkflow.SaveViAsync(cancellationToken).ConfigureAwait(false); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot AddManualViPlaylistRow() + { + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "섹션 수동 입력 중에는 개별 컷을 편성표에 추가할 수 없습니다."); + } + + if (_manualListsWorkflow is null) + { + return ReportError("VI 수동 목록 기능을 사용할 수 없습니다."); + } + + try + { + AppendManualListRow(_manualListsWorkflow.CreateViPlaylistDraft()); + _statusMessage = "검증된 VI 발동 컷을 편성표에 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + } + + Touch(); + return CreateSnapshot(); + } + + /// + /// Reproduces the original FSell/VI OK button: persist once, verify the fresh + /// readback, append the corresponding cut, and close the editor as one operator + /// action. A failed or outcome-unknown save leaves the editor open and never + /// attempts playlist materialization or an automatic retry. + /// + public async Task ConfirmManualListsAsync( + CancellationToken cancellationToken = default) + { + if (_manualListsWorkflow is null) + { + return ReportError("수동 목록 기능을 사용할 수 없습니다."); + } + + var screen = _manualListsWorkflow.Current.Screen; + if (screen is null) + { + return ReportError("열려 있는 수동 목록 입력창이 없습니다."); + } + + if (_pendingFixedSectionBatch is { } pending && + (GetActiveFixedMarket() != pending.Market || + !pending.IsCurrentManual( + screen.Value, + _manualListsWorkflow.Current.Audience))) + { + return ReportError( + "고정 컷 섹션의 현재 수동 입력 단계와 열린 입력창이 일치하지 않아 저장하지 않았습니다."); + } + + if (screen == LegacyManualListScreen.NetSell) + { + await _manualListsWorkflow.SaveNetSellAsync(cancellationToken) + .ConfigureAwait(false); + } + else + { + await _manualListsWorkflow.SaveViAsync(cancellationToken) + .ConfigureAwait(false); + } + + var saved = _manualListsWorkflow.Current; + if (!saved.CanMaterializePlaylist) + { + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + try + { + var draft = screen == LegacyManualListScreen.NetSell + ? _manualListsWorkflow.CreateNetSellPlaylistDraft() + : _manualListsWorkflow.CreateViPlaylistDraft(); + + if (_pendingFixedSectionBatch is not null) + { + _pendingFixedSectionBatch.StageCurrentManual(draft); + _manualListsWorkflow.Close(); + return await ContinuePendingFixedSectionAsync(cancellationToken) + .ConfigureAwait(false); + } + + AppendManualListRow(draft); + _manualListsWorkflow.Close(); + _statusMessage = screen == LegacyManualListScreen.NetSell + ? "수동 순매도 목록을 저장하고 편성표에 추가했습니다." + : "VI 발동 목록을 저장하고 편성표에 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + } + + Touch(); + return CreateSnapshot(); + } + + public async Task ImportLegacyManualListsAsync( + CancellationToken cancellationToken = default) + { + if (_manualListsWorkflow is null) + { + return ReportError("원본 수동 데이터 가져오기 기능을 사용할 수 없습니다."); + } + + await _manualListsWorkflow.ImportAsync(cancellationToken).ConfigureAwait(false); + ApplyManualListsStatus(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ActivateFixedAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요."); + } + + var market = GetActiveFixedMarket(); + if (market is null) + { + return ReportError("현재 탭에서는 고정 컷을 선택할 수 없습니다."); + } + + try + { + var materialized = _fixedCatalog.Materialize( + actionId, + _timeProvider.GetLocalNow(), + _movingAverages); + if (materialized.Action.Market != market.Value) + { + return ReportError("현재 탭에 속하지 않는 항목입니다."); + } + + AppendFixedRow(materialized); + _statusMessage = $"{materialized.Action.Label} 항목을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + catch (KeyNotFoundException) + { + return ReportError("등록되지 않은 고정 컷 항목입니다."); + } + } + + public async Task ActivateFixedActionAsync( + string actionId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(actionId); + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "고정 컷 섹션의 수동 입력을 완료하거나 취소한 뒤 다른 항목을 선택하세요."); + } + + var manualTarget = GetFixedManualTarget(actionId); + + if (manualTarget is null) + { + return ActivateFixedAction(actionId); + } + + var market = GetActiveFixedMarket(); + if (market != LegacyFixedMarket.Index) + { + return ReportError("현재 탭에 속하지 않는 수동 입력 항목입니다."); + } + + return manualTarget.Value.Screen == LegacyManualListScreen.NetSell + ? await OpenManualNetSellAsync( + manualTarget.Value.Audience, + cancellationToken).ConfigureAwait(false) + : await OpenManualViAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Preserves UC1 parent-node direct-child order while closing its manual-child gap: + /// the original parent path removed FSell rows and returned at VI, because the + /// ShowDialog calls lived only in the leaf path. Automatic children are materialized + /// into a native staging area first; FSell/VI children are opened one at a time and + /// the playlist is committed only after every manual child has been confirmed. + /// + public async Task ActivateFixedSection( + int sectionIndex, + CancellationToken cancellationToken = default) + { + var market = GetActiveFixedMarket(); + if (market is null) + { + return ReportError("현재 탭에서는 고정 컷 섹션을 선택할 수 없습니다."); + } + + var sections = _fixedCatalog.GetSections(market.Value); + if (sectionIndex < 0 || sectionIndex >= sections.Count) + { + return ReportError("등록되지 않은 고정 컷 섹션입니다."); + } + + if (_pendingFixedSectionBatch is not null) + { + return ReportError( + "이미 고정 컷 섹션의 수동 입력을 진행하고 있습니다. 먼저 완료하거나 취소하세요."); + } + + if (_manualListsWorkflow?.Current.IsOpen == true) + { + return ReportError("열려 있는 수동 입력창을 먼저 완료하거나 닫으세요."); + } + + var section = sections[sectionIndex]; + try + { + var localNow = _timeProvider.GetLocalNow(); + var items = new List(section.Actions.Count); + foreach (var action in section.Actions) + { + var manualTarget = GetFixedManualTarget(action.Id); + if (manualTarget is not null) + { + if (_manualListsWorkflow is null) + { + return ReportError( + "이 섹션에는 수동 입력 항목이 있지만 수동 목록 기능을 사용할 수 없습니다."); + } + + items.Add(new PendingFixedSectionItem(action, manualTarget.Value)); + continue; + } + + // Every non-manual child is validated before the first playlist row + // is appended or the first modal editor is opened. + items.Add(new PendingFixedSectionItem( + action, + _fixedCatalog.Materialize( + action.Id, + localNow, + _movingAverages))); + } + + var manualCount = items.Count(item => item.ManualTarget is not null); + if (manualCount == 0) + { + CommitFixedSectionBatch(items); + _statusMessage = $"{section.Name} 항목 {items.Count}개를 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + + _pendingFixedSectionBatch = new PendingFixedSectionBatch( + market.Value, + sectionIndex, + section.Name, + items, + manualCount); + return await OpenPendingFixedSectionManualAsync(cancellationToken) + .ConfigureAwait(false); + } + catch (InvalidOperationException) + { + _pendingFixedSectionBatch = null; + return ReportError("섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다."); + } + } + + public LegacyOperatorSnapshot SelectIndustry(int index, bool doubleClick) + { + if (!IsActiveIndustryReady()) + { + return ReportError("업종 목록을 먼저 불러오세요."); + } + + if (doubleClick) + { + _industryWorkflow!.DoubleClickIndustry(index); + } + else + { + _industryWorkflow!.SelectIndustry(index); + } + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SwapIndustries() + { + if (!IsActiveIndustryReady()) + { + return ReportError("업종 목록을 먼저 불러오세요."); + } + + _industryWorkflow!.SwapIndustries(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ActivateIndustryAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!IsActiveIndustryReady()) + { + return ReportError("업종 목록을 먼저 불러오세요."); + } + + try + { + var draft = _industryWorkflow!.MaterializePlaylistDraft(actionId); + AppendIndustryRow(draft, _industryWorkflow.Current.Industries.Count); + _statusMessage = $"{draft.Detail} 항목을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (LegacyIndustryWorkflowException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + public LegacyOperatorSnapshot ActivateIndustrySection(int sectionIndex) + { + if (!IsActiveIndustryReady()) + { + return ReportError("업종 목록을 먼저 불러오세요."); + } + + var snapshot = _industryWorkflow!.Current; + var sections = snapshot.Actions + .Select(static action => action.Section) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (sectionIndex < 0 || sectionIndex >= sections.Length) + { + return ReportError("등록되지 않은 업종 컷 섹션입니다."); + } + + try + { + var section = sections[sectionIndex]; + // Materialize every direct child before mutating the playlist. This keeps + // the original parent double-click all-or-nothing when one child lacks a + // required industry selection. + var drafts = snapshot.Actions + .Where(action => string.Equals( + action.Section, + section, + StringComparison.Ordinal)) + .Select(action => _industryWorkflow.MaterializePlaylistDraft(action.ActionId)) + .ToArray(); + foreach (var draft in drafts) + { + AppendIndustryRow(draft, snapshot.Industries.Count); + } + + _statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (LegacyIndustryWorkflowException exception) + { + _dialog = new LegacyDialogState( + $"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}"); + Touch(); + return CreateSnapshot(); + } + } + + public LegacyOperatorSnapshot SelectOverseasFixedIndex(string targetId) + { + ArgumentNullException.ThrowIfNull(targetId); + if (!IsOverseasActive() || _overseasWorkflow is null) + { + return ReportError("해외종목 탭을 먼저 선택하세요."); + } + + try + { + var selected = _overseasWorkflow.SelectFixedIndex(targetId).SelectedFixedIndex!; + _statusMessage = $"{selected.DisplayName} 지수를 선택했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (KeyNotFoundException) + { + return ReportError("등록되지 않은 해외지수입니다."); + } + } + + public async Task SearchOverseasIndustriesAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsOverseasActive() || _overseasWorkflow is null) + { + return ReportError("해외종목 탭을 먼저 선택하세요."); + } + + var overseas = await _overseasWorkflow.SearchIndustriesAsync( + query, + cancellationToken: cancellationToken).ConfigureAwait(false); + _statusMessage = $"해외업종 {overseas.Industry.ResultCount}건을 찾았습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + + public async Task SearchOverseasStocksAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsOverseasActive() || _overseasWorkflow is null) + { + return ReportError("해외종목 탭을 먼저 선택하세요."); + } + + var overseas = await _overseasWorkflow.SearchStocksAsync( + query, + cancellationToken: cancellationToken).ConfigureAwait(false); + _statusMessage = $"해외종목 {overseas.Stock.ResultCount}건을 찾았습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectOverseasIndustry(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsOverseasActive() || _overseasWorkflow is null) + { + return ReportError("해외종목 탭을 먼저 선택하세요."); + } + + try + { + var selected = _overseasWorkflow.SelectIndustry(selectionId).Industry.Selected!; + _statusMessage = $"{selected.KoreanName} 업종을 선택했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (KeyNotFoundException) + { + return ReportError("현재 검색 결과에 없는 해외업종입니다."); + } + } + + public LegacyOperatorSnapshot SelectOverseasStock(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsOverseasActive() || _overseasWorkflow is null) + { + return ReportError("해외종목 탭을 먼저 선택하세요."); + } + + try + { + var selected = _overseasWorkflow.SelectStock(selectionId).Stock.Selected!; + _statusMessage = $"{selected.InputName} 종목을 선택했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (KeyNotFoundException) + { + return ReportError("현재 검색 결과에 없는 해외종목입니다."); + } + } + + public LegacyOperatorSnapshot ActivateOverseasAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!IsOverseasActive() || _overseasWorkflow is null) + { + return ReportError("해외종목 탭을 먼저 선택하세요."); + } + + try + { + var draft = _overseasWorkflow.MaterializePlaylistDraft(actionId); + AppendOverseasRow(draft); + _statusMessage = $"{draft.Title} 항목을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (LegacyOverseasWorkflowException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + public async Task SearchComparisonDomesticAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + await _comparisonWorkflow.SearchDomesticAsync(query, cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + + public async Task SearchComparisonWorldAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + await _comparisonWorkflow.SearchWorldAsync(query, cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectComparisonDomesticResult(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("Select the comparison tab first."); + } + + _comparisonWorkflow.SelectDomesticResult(selectionId); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectComparisonWorldResult(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("Select the comparison tab first."); + } + + _comparisonWorkflow.SelectWorldResult(selectionId); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectComparisonFixedTarget(string targetId) + { + ArgumentNullException.ThrowIfNull(targetId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("Select the comparison tab first."); + } + + _comparisonWorkflow.SelectFixedTarget(targetId); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ChooseComparisonDomesticResult(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + _comparisonWorkflow.ChooseDomesticResult(selectionId); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ChooseComparisonWorldResult(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + _comparisonWorkflow.ChooseWorldResult(selectionId); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ChooseComparisonFixedTarget(string targetId) + { + ArgumentNullException.ThrowIfNull(targetId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + _comparisonWorkflow.ChooseFixedTarget(targetId); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SwapComparisonTargets() + { + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + _comparisonWorkflow.SwapDraftTargets(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ClearComparisonTargets() + { + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + _comparisonWorkflow.ClearDraftTargets(); + Touch(); + return CreateSnapshot(); + } + + public async Task AddComparisonPairAsync( + CancellationToken cancellationToken = default) + { + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + try + { + await _comparisonWorkflow.AddDraftPairAsync(cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + } + + public LegacyOperatorSnapshot SelectComparisonPair(string rowId) + { + ArgumentNullException.ThrowIfNull(rowId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + _comparisonWorkflow.SelectSavedPair(rowId); + Touch(); + return CreateSnapshot(); + } + + public async Task MoveComparisonPairAsync( + LegacyComparisonPairMoveDirection direction, + CancellationToken cancellationToken = default) + { + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + await _comparisonWorkflow.MoveSelectedPairAsync(direction, cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + + public async Task DeleteComparisonPairAsync( + bool all, + CancellationToken cancellationToken = default) + { + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + if (all) + { + await _comparisonWorkflow.DeleteAllPairsAsync(cancellationToken) + .ConfigureAwait(false); + } + else + { + await _comparisonWorkflow.DeleteSelectedPairAsync(cancellationToken) + .ConfigureAwait(false); + } + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ActivateComparisonAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + try + { + var draft = _comparisonWorkflow.MaterializeSelectedAction(actionId); + AppendComparisonRow(draft); + _statusMessage = $"{draft.Detail} 항목을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentOutOfRangeException) + { + return ReportError("등록되지 않은 비교 컷 항목입니다."); + } + } + + public LegacyOperatorSnapshot ActivateComparisonSection(int sectionIndex) + { + if (!IsComparisonActive() || _comparisonWorkflow is null) + { + return ReportError("비교 탭을 먼저 선택하세요."); + } + + var snapshot = _comparisonWorkflow.Current; + var sections = snapshot.Actions + .Select(static action => action.Section) + .Distinct(StringComparer.Ordinal) + .ToArray(); + if (sectionIndex < 0 || sectionIndex >= sections.Length) + { + return ReportError("등록되지 않은 비교 컷 섹션입니다."); + } + + var section = sections[sectionIndex]; + var selectedPair = snapshot.SavedPairs.FirstOrDefault(static pair => pair.IsSelected); + if (string.Equals(section, "종목별 수익률 비교", StringComparison.Ordinal) && + selectedPair is not null && + (selectedPair.First.Kind == LegacyComparisonTargetKind.NxtStock || + selectedPair.Second.Kind == LegacyComparisonTargetKind.NxtStock)) + { + _dialog = new LegacyDialogState( + "NXT 종목이 포함된 비교쌍에는 종목별 수익률 비교 섹션을 추가할 수 없습니다."); + Touch(); + return CreateSnapshot(); + } + + var actions = snapshot.Actions + .Where(action => string.Equals( + action.Section, + section, + StringComparison.Ordinal)) + .ToArray(); + if (actions.Length == 0 || actions.Any(static action => !action.IsAvailable)) + { + _dialog = new LegacyDialogState( + "섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다."); + Touch(); + return CreateSnapshot(); + } + + try + { + var drafts = actions + .Select(action => _comparisonWorkflow.MaterializeSelectedAction(action.ActionId)) + .ToArray(); + foreach (var draft in drafts) + { + AppendComparisonRow(draft); + } + + _statusMessage = $"{section} 항목 {drafts.Length}개를 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState( + $"섹션의 모든 항목을 검증하지 못해 아무것도 추가하지 않았습니다. {exception.Message}"); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentOutOfRangeException) + { + return ReportError("등록되지 않은 비교 컷 항목입니다."); + } + } + + public async Task SearchThemesAsync( + string query, + MMoneyCoderSharp.Data.ThemeSession nxtSession, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null) + { + return ReportError("테마 탭을 먼저 선택하세요."); + } + + _themeSnapshot = await _themeWorkflow.SearchAsync( + _themeSnapshot, + query, + nxtSession, + cancellationToken).ConfigureAwait(false); + _themeActivationListLoaded = true; + Touch(); + return CreateSnapshot(); + } + + public async Task SelectThemeAsync( + string resultId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resultId); + if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null) + { + return ReportError("테마 탭을 먼저 선택하세요."); + } + + try + { + _themeSnapshot = await _themeWorkflow.SelectAsync( + _themeSnapshot, + resultId, + cancellationToken).ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + catch (KeyNotFoundException) + { + return ReportError("등록되지 않은 테마 검색 결과입니다."); + } + } + + public async Task ActivateThemeResultAsync( + string resultId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(resultId); + const string command = "activate-theme-result"; + const string originalDoubleClickAction = "five-row-current"; + if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null) + { + CompleteCommand(command, resultId, succeeded: false); + return ReportError("Select the theme tab before activating a result."); + } + + try + { + // The original row double-click first selected the row, then emitted the + // fixed five-row current-price cut. Await preview loading so those stages + // cannot race each other. + _themeSnapshot = await _themeWorkflow.SelectAsync( + _themeSnapshot, + resultId, + cancellationToken).ConfigureAwait(false); + var draft = _themeWorkflow.MaterializePlaylistDraft( + _themeSnapshot, + originalDoubleClickAction); + AppendThemeRow(draft); + _statusMessage = $"{draft.Detail} item was added."; + _statusKind = LegacyOperatorStatusKind.Information; + CompleteCommand(command, resultId, succeeded: true); + Touch(); + return CreateSnapshot(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is ArgumentException or + InvalidOperationException or KeyNotFoundException or + LegacyThemeWorkflowDataException) + { + _dialog = new LegacyDialogState(exception.Message); + _statusMessage = exception.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + CompleteCommand(command, resultId, succeeded: false); + Touch(); + return CreateSnapshot(); + } + } + + public LegacyOperatorSnapshot SetThemeSort(string sortId) + { + ArgumentNullException.ThrowIfNull(sortId); + if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null) + { + return ReportError("테마 탭을 먼저 선택하세요."); + } + + _themeSnapshot = _themeWorkflow.SelectSort(_themeSnapshot, sortId); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ActivateThemeAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!IsThemeActive() || _themeWorkflow is null || _themeSnapshot is null) + { + return ReportError("테마 탭을 먼저 선택하세요."); + } + + try + { + var draft = _themeWorkflow.MaterializePlaylistDraft(_themeSnapshot, actionId); + AppendThemeRow(draft); + _statusMessage = $"{draft.Detail} 항목을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + catch (KeyNotFoundException) + { + return ReportError("등록되지 않은 테마 컷 항목입니다."); + } + } + + public async Task SearchExpertsAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsExpertActive() || _expertWorkflow is null) + { + return ReportError("전문가 탭을 먼저 선택하세요."); + } + + await _expertWorkflow.SearchAsync(query, cancellationToken).ConfigureAwait(false); + _expertActivationListLoaded = true; + Touch(); + return CreateSnapshot(); + } + + public async Task SelectExpertAsync( + string selectionId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsExpertActive() || _expertWorkflow is null) + { + return ReportError("전문가 탭을 먼저 선택하세요."); + } + + try + { + await _expertWorkflow.SelectExpertAsync(selectionId, cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentOutOfRangeException) + { + return ReportError("등록되지 않은 전문가 검색 결과입니다."); + } + } + + public LegacyOperatorSnapshot ActivateExpertAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!IsExpertActive() || _expertWorkflow is null) + { + return ReportError("전문가 탭을 먼저 선택하세요."); + } + + try + { + var draft = _expertWorkflow.MaterializeSelectedAction(actionId); + AppendExpertRow(draft); + _statusMessage = $"{draft.Title} 추천 항목을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentOutOfRangeException) + { + return ReportError("등록되지 않은 전문가 컷 항목입니다."); + } + } + + public async Task SearchTradingHaltsAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsTradingHaltActive() || _tradingHaltWorkflow is null) + { + return ReportError("정지 탭을 먼저 선택하세요."); + } + + await _tradingHaltWorkflow.SearchAsync(query, cancellationToken) + .ConfigureAwait(false); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectTradingHalt(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + if (!IsTradingHaltActive() || _tradingHaltWorkflow is null) + { + return ReportError("정지 탭을 먼저 선택하세요."); + } + + try + { + _tradingHaltWorkflow.SelectResult(selectionId); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentOutOfRangeException) + { + return ReportError("등록되지 않은 거래정지 검색 결과입니다."); + } + } + + public LegacyOperatorSnapshot ToggleTradingHaltSort() + { + if (!IsTradingHaltActive() || _tradingHaltWorkflow is null) + { + return ReportError("정지 탭을 먼저 선택하세요."); + } + + _tradingHaltWorkflow.ToggleNameSort(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ActivateTradingHaltAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + if (!IsTradingHaltActive() || _tradingHaltWorkflow is null) + { + return ReportError("정지 탭을 먼저 선택하세요."); + } + + try + { + var draft = _tradingHaltWorkflow.MaterializeSelectedAction(actionId); + AppendTradingHaltRow(draft); + _statusMessage = $"{draft.Title} 항목을 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + catch (InvalidOperationException exception) + { + _dialog = new LegacyDialogState(exception.Message); + Touch(); + return CreateSnapshot(); + } + catch (ArgumentOutOfRangeException) + { + return ReportError("등록되지 않은 거래정지 컷 항목입니다."); + } + } + + public LegacyOperatorSnapshot SelectPlaylistRow( + string rowId, + bool control, + bool shift) + { + ArgumentNullException.ThrowIfNull(rowId); + var index = FindPlaylistIndex(rowId); + if (index < 0) + { + return CreateSnapshot(); + } + + _activePlaylistRowId = rowId; + + // MainForm handled Control before Shift. Consequently Ctrl+Shift added only + // the clicked row, and clicking an already selected row did not remove it. + if (control) + { + _selectedPlaylistRowIds.Add(rowId); + } + else + { + if (_playlistCursorIndex != index) + { + _playlistPriorCursorIndex = _playlistCursorIndex; + _playlistCursorIndex = index; + } + + _selectedPlaylistRowIds.Clear(); + if (shift) + { + AddPlaylistRange(_playlistPriorCursorIndex, _playlistCursorIndex); + } + else + { + _selectedPlaylistRowIds.Add(rowId); + } + } + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot ToggleActivePlaylistRowEnabled() + { + return _activePlaylistRowId is null + ? CreateSnapshot() + : TogglePlaylistRowEnabled(_activePlaylistRowId); + } + + public LegacyOperatorSnapshot SelectPlaylistBoundary(bool last) + { + if (_playlist.Count == 0) + { + return CreateSnapshot(); + } + + var index = last ? _playlist.Count - 1 : 0; + var rowId = _playlist[index].RowId; + _selectedPlaylistRowIds.Clear(); + _selectedPlaylistRowIds.Add(rowId); + _activePlaylistRowId = rowId; + _playlistPriorCursorIndex = index; + _playlistCursorIndex = index; + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot DeleteSelectedPlaylistRows() + { + if (_selectedPlaylistRowIds.Count == 0) + { + return CreateSnapshot(); + } + + _playlist.RemoveAll(row => _selectedPlaylistRowIds.Contains(row.RowId)); + ResetPlaylistSelection(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot DeleteAllPlaylistRows() + { + if (_playlist.Count == 0) + { + return CreateSnapshot(); + } + + _playlist.Clear(); + ResetPlaylistSelection(); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot MoveSelectedPlaylistRows( + LegacyPlaylistMoveDirection direction) + { + var activeIndex = _activePlaylistRowId is null + ? -1 + : FindPlaylistIndex(_activePlaylistRowId); + if (activeIndex < 0) + { + return CreateSnapshot(); + } + + var targetIndex = direction switch + { + LegacyPlaylistMoveDirection.Up => activeIndex - 1, + LegacyPlaylistMoveDirection.Down => activeIndex + 1, + _ => throw new ArgumentOutOfRangeException(nameof(direction), direction, null) + }; + if (targetIndex < 0 || targetIndex >= _playlist.Count) + { + return CreateSnapshot(); + } + + // Spd_UpDown exchanged all seven cell values for the active row only. It did + // not move an entire multi-selection as a block. + (_playlist[activeIndex], _playlist[targetIndex]) = + (_playlist[targetIndex], _playlist[activeIndex]); + + _selectedPlaylistRowIds.Clear(); + _selectedPlaylistRowIds.Add(_activePlaylistRowId!); + _playlistPriorCursorIndex = targetIndex; + _playlistCursorIndex = targetIndex; + Touch(); + return CreateSnapshot(); + } + + public async Task RefreshNamedPlaylistsAsync( + CancellationToken cancellationToken = default) + { + if (_namedPlaylistWorkflow is null) + { + return ReportError("DB 재생목록 기능이 구성되지 않았습니다."); + } + + try + { + var namedPlaylist = await _namedPlaylistWorkflow.RefreshAsync( + cancellationToken: cancellationToken).ConfigureAwait(false); + ApplyNamedPlaylistStatus(namedPlaylist); + } + catch (OperationCanceledException) + { + throw; + } + catch + { + ApplyNamedPlaylistStatus( + _namedPlaylistWorkflow.Current, + "DB 재생목록 목록을 불러오지 못했습니다.", + LegacyOperatorStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorSnapshot SelectNamedPlaylist(string definitionId) + { + ArgumentNullException.ThrowIfNull(definitionId); + if (_namedPlaylistWorkflow is null) + { + return ReportError("DB 재생목록 기능이 구성되지 않았습니다."); + } + + try + { + ApplyNamedPlaylistStatus( + _namedPlaylistWorkflow.SelectDefinition(definitionId)); + } + catch (Exception exception) when (exception is KeyNotFoundException or + InvalidOperationException or ArgumentException) + { + _dialog = new LegacyDialogState( + "선택한 DB 재생목록이 더 이상 유효하지 않습니다. 목록을 새로 고치세요."); + _statusMessage = _dialog.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + } + + Touch(); + return CreateSnapshot(); + } + + public async Task LoadSelectedNamedPlaylistAsync( + CancellationToken cancellationToken = default) + { + if (_namedPlaylistWorkflow is null) + { + return ReportError("DB 재생목록 기능이 구성되지 않았습니다."); + } + + try + { + var namedPlaylist = await _namedPlaylistWorkflow.LoadSelectedAsync( + cancellationToken).ConfigureAwait(false); + var loadedRows = namedPlaylist.Rows + .Select(CreateNamedPlaylistOperatorRow) + .ToArray(); + + _playlist.Clear(); + _playlist.AddRange(loadedRows); + ResetPlaylistSelection(); + ApplyNamedPlaylistStatus(namedPlaylist); + } + catch (OperationCanceledException) + { + throw; + } + catch (InvalidDataException) + { + _dialog = new LegacyDialogState( + "DB 재생목록에 현재 버전에서 안전하게 식별할 수 없는 컷이 있어 기존 송출 목록을 유지했습니다."); + _statusMessage = _dialog.Message; + _statusKind = LegacyOperatorStatusKind.Error; + } + catch (Exception exception) when (exception is KeyNotFoundException or + InvalidOperationException or ArgumentException) + { + _dialog = new LegacyDialogState("불러올 DB 재생목록을 먼저 선택하세요."); + _statusMessage = _dialog.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + } + catch + { + ApplyNamedPlaylistStatus( + _namedPlaylistWorkflow.Current, + "선택한 DB 재생목록을 불러오지 못했습니다.", + LegacyOperatorStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + public async Task LoadNamedPlaylistByIdAsync( + string definitionId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(definitionId); + const string command = "load-named-playlist-by-id"; + var selected = SelectNamedPlaylist(definitionId); + if (!string.Equals( + selected.NamedPlaylist?.SelectedDefinitionId, + definitionId, + StringComparison.Ordinal)) + { + CompleteCommand(command, definitionId, succeeded: false); + Touch(); + return CreateSnapshot(); + } + + var loaded = await LoadSelectedNamedPlaylistAsync(cancellationToken) + .ConfigureAwait(false); + var succeeded = + string.Equals( + loaded.NamedPlaylist?.SelectedDefinitionId, + definitionId, + StringComparison.Ordinal) && + loaded.NamedPlaylist?.ReadStatus == LegacyNamedPlaylistReadStatus.Ready && + loaded.NamedPlaylist?.DocumentFreshness == LegacyNamedPlaylistFreshness.Fresh && + loaded.StatusKind == LegacyOperatorStatusKind.Information; + CompleteCommand(command, definitionId, succeeded); + Touch(); + return CreateSnapshot(); + } + + public async Task CreateNamedPlaylistAsync( + string title, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(title); + if (_namedPlaylistWorkflow is null) + { + return ReportError("DB 재생목록 기능이 구성되지 않았습니다."); + } + + try + { + var draft = _namedPlaylistWorkflow.CreateProgramDraft(title); + var result = await _namedPlaylistWorkflow.CreateProgramAsync( + draft, + cancellationToken).ConfigureAwait(false); + ApplyNamedPlaylistStatus(result.Snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is ArgumentException or + InvalidOperationException) + { + _dialog = new LegacyDialogState( + "새 DB 재생목록을 만들 수 없습니다. 목록을 새로 고친 뒤 이름을 확인하세요."); + _statusMessage = _dialog.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + } + + Touch(); + return CreateSnapshot(); + } + + public async Task SaveCurrentNamedPlaylistAsync( + CancellationToken cancellationToken = default) + { + if (_namedPlaylistWorkflow is null) + { + return ReportError("DB 재생목록 기능이 구성되지 않았습니다."); + } + + try + { + if (_playlist.Count > LegacyNamedPlaylistWorkflowController.MaximumItems) + { + throw new InvalidOperationException("The playlist is too large to save."); + } + + // DC_LIST/PLAY_LIST compatibility is exactly the original seven visible/hidden + // fields. Do not persist the newer internal scene selection or scene alias. + var rows = _playlist.Select(row => + { + var persistedSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( + row.MarketText, + row.StockName, + row.GraphicType, + row.Subtype, + row.StockCode); + var entry = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacyPlaylistEntry( + row.RowId, + row.SceneAlias, + row.IsEnabled, + row.FadeDuration, + persistedSelection); + return _namedPlaylistWorkflow.CreateRowDraft( + entry, + ParseNamedPlaylistPage(row.PageText)); + }).ToArray(); + + var result = await _namedPlaylistWorkflow.SaveCurrentAsync( + rows, + cancellationToken).ConfigureAwait(false); + ApplyNamedPlaylistStatus(result.Snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is ArgumentException or + InvalidOperationException or FormatException or OverflowException) + { + _dialog = new LegacyDialogState( + "현재 송출 목록을 저장할 수 없습니다. DB 재생목록 선택과 Page 값을 확인하세요."); + _statusMessage = _dialog.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + } + + Touch(); + return CreateSnapshot(); + } + + public async Task SaveCurrentNamedPlaylistToAsync( + string definitionId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(definitionId); + const string command = "save-current-named-playlist-to"; + var selected = SelectNamedPlaylist(definitionId); + if (!string.Equals( + selected.NamedPlaylist?.SelectedDefinitionId, + definitionId, + StringComparison.Ordinal)) + { + CompleteCommand(command, definitionId, succeeded: false); + Touch(); + return CreateSnapshot(); + } + + var saved = await SaveCurrentNamedPlaylistAsync(cancellationToken) + .ConfigureAwait(false); + var succeeded = + string.Equals( + saved.NamedPlaylist?.SelectedDefinitionId, + definitionId, + StringComparison.Ordinal) && + saved.NamedPlaylist?.LastMutationOutcome is + LegacyNamedPlaylistMutationOutcome.CommittedFresh or + LegacyNamedPlaylistMutationOutcome.CommittedOptimistic && + saved.StatusKind == LegacyOperatorStatusKind.Information; + CompleteCommand(command, definitionId, succeeded); + Touch(); + return CreateSnapshot(); + } + + public async Task DeleteSelectedNamedPlaylistAsync( + CancellationToken cancellationToken = default) + { + if (_namedPlaylistWorkflow is null) + { + return ReportError("DB 재생목록 기능이 구성되지 않았습니다."); + } + + try + { + var result = await _namedPlaylistWorkflow.DeleteSelectedAsync( + cancellationToken).ConfigureAwait(false); + ApplyNamedPlaylistStatus(result.Snapshot); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) when (exception is ArgumentException or + InvalidOperationException) + { + _dialog = new LegacyDialogState("삭제할 DB 재생목록을 먼저 선택하세요."); + _statusMessage = _dialog.Message; + _statusKind = LegacyOperatorStatusKind.Warning; + } + + Touch(); + return CreateSnapshot(); + } + public LegacyOperatorSnapshot DismissDialog() { if (_dialog is not null) @@ -201,6 +3084,14 @@ public sealed class LegacyOperatorController return CreateSnapshot(); } + public LegacyOperatorSnapshot ReportInformation(string message) + { + _statusMessage = message ?? string.Empty; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + private void ContinuePendingDrop() { while (_pendingDropCutRows is { Count: > 0 }) @@ -338,6 +3229,206 @@ public sealed class LegacyOperatorController } } + private void ApplyNamedPlaylistStatus( + LegacyNamedPlaylistWorkflowSnapshot snapshot, + string? fallbackMessage = null, + LegacyOperatorStatusKind? fallbackKind = null) + { + _statusMessage = string.IsNullOrWhiteSpace(snapshot.StatusMessage) + ? fallbackMessage ?? string.Empty + : snapshot.StatusMessage; + _statusKind = fallbackKind ?? snapshot.StatusKind; + } + + private static MMoneyCoderSharp.Data.NamedPlaylistPageState? ParseNamedPlaylistPage( + string pageText) + { + ArgumentNullException.ThrowIfNull(pageText); + if (pageText.Length == 0) + { + return null; + } + + if (!string.Equals(pageText, pageText.Trim(), StringComparison.Ordinal)) + { + throw new FormatException("A named-playlist page must be canonical."); + } + + var parts = pageText.Split('/'); + if (parts.Length != 2 || + !int.TryParse( + parts[0], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var currentPage) || + !int.TryParse( + parts[1], + System.Globalization.NumberStyles.None, + System.Globalization.CultureInfo.InvariantCulture, + out var totalPages)) + { + throw new FormatException("A named-playlist page must use current/total form."); + } + + return new MMoneyCoderSharp.Data.NamedPlaylistPageState(currentPage, totalPages); + } + + private LegacyOperatorPlaylistRow CreateNamedPlaylistOperatorRow( + LegacyNamedPlaylistRowView row) + { + var selection = row.Selection; + var sceneAlias = ResolveNamedPlaylistSceneAlias(selection); + var playoutSelection = new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + selection.DataCode); + var rawCutLabel = selection.Subtype.Length == 0 + ? selection.GraphicType + : $"{selection.GraphicType}_{selection.Subtype}"; + return new LegacyOperatorPlaylistRow( + $"legacy-named-{++_playlistSequence:D8}", + row.IsEnabled, + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + row.Page?.PageText ?? string.Empty, + selection.DataCode, + rawCutLabel, + 6, + SceneAlias: sceneAlias, + PlayoutSelection: playoutSelection); + } + + private string ResolveNamedPlaylistSceneAlias( + LegacyNamedPlaylistSelectionView selection) + { + var rawLabel = selection.Subtype.Length == 0 + ? selection.GraphicType + : $"{selection.GraphicType}_{selection.Subtype}"; + + // Stock and trading-halt rows retain the original stock-market and cut-label + // columns, so the closed stock catalog remains the authority for their alias. + if (selection.GroupCode is "코스피" or "코스닥" or + "코스피_NXT" or "코스닥_NXT") + { + try + { + return LegacyStockCutCatalog.ResolveCutAlias( + rawLabel, + selection.GroupCode.EndsWith("_NXT", StringComparison.Ordinal) || + selection.Subject.Contains("(NXT)", StringComparison.Ordinal)); + } + catch (InvalidOperationException) + { + // Continue through the other closed registries. This also permits + // manual-financial rows whose market column resembles a stock market. + } + } + + var fixedMarket = selection.GroupCode switch + { + "해외지수" => LegacyFixedMarket.Overseas, + "환율" => LegacyFixedMarket.Exchange, + "주요지수" => LegacyFixedMarket.Index, + _ => (LegacyFixedMarket?)null + }; + if (fixedMarket is { } market) + { + var actions = _fixedCatalog.GetActions(market) + .Where(action => + string.Equals(action.Label, selection.Subject, StringComparison.Ordinal) && + string.Equals(action.Section, selection.GraphicType, StringComparison.Ordinal) && + string.Equals(action.Detail, selection.Subtype, StringComparison.Ordinal)) + .Take(2) + .ToArray(); + if (actions.Length == 1) + { + return actions[0].Code; + } + } + + var industryMarket = selection.GroupCode switch + { + "업종_코스피" => MMoneyCoderSharp.Data.IndustryMarket.Kospi, + "업종_코스닥" => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq, + _ => (MMoneyCoderSharp.Data.IndustryMarket?)null + }; + if (industryMarket is { } industry) + { + var actions = LegacyIndustryActionCatalog.GetActions(industry) + .Where(action => string.Equals( + action.Label, + rawLabel, + StringComparison.Ordinal)) + .Take(2) + .ToArray(); + if (actions.Length == 1) + { + return actions[0].SceneAlias; + } + } + + if (selection.GroupCode is "테마" or "테마_NXT") + { + var detail = selection.Subtype.Length == 0 + ? selection.GraphicType + : $"{selection.GraphicType} · {selection.Subtype}"; + var actions = LegacyThemeWorkflow.Actions + .Where(action => LegacyThemeWorkflow.Sorts.Any(sort => string.Equals( + $"{action.Label} · {sort.Label}", + detail, + StringComparison.Ordinal))) + .Take(2) + .ToArray(); + if (actions.Length == 1) + { + return actions[0].Code; + } + } + + if (string.Equals(selection.GroupCode, "전문가", StringComparison.Ordinal) && + string.Equals(selection.GraphicType, "5단 표그래프", StringComparison.Ordinal) && + string.Equals(selection.Subtype, "현재가", StringComparison.Ordinal)) + { + return "5074"; + } + + if (string.Equals(selection.GroupCode, "비교", StringComparison.Ordinal)) + { + var alias = selection.GraphicType switch + { + "s5032" => "5032", + "s8018" => "8032", + "s5029" => "5029", + "s5026" => "5026", + "s5087" => "5087", + _ => null + }; + if (alias is not null) + { + return alias; + } + } + + var manualFinancial = LegacyManualFinancialWorkflow.Screens + .Where(definition => + string.Equals(definition.Label, selection.GraphicType, StringComparison.Ordinal) && + (selection.GroupCode is "KOSPI" or "KOSDAQ" or + "NXT_KOSPI" or "NXT_KOSDAQ")) + .Take(2) + .ToArray(); + if (manualFinancial.Length == 1) + { + return manualFinancial[0].Code; + } + + throw new InvalidDataException( + "The named-playlist row does not map to a registered scene alias."); + } + private LegacyOperatorSnapshot CreateSnapshot() { var searchRows = _searchData.DisplayNames @@ -352,20 +3443,663 @@ public sealed class LegacyOperatorController _nativeSelectedCutRows.Contains(row.PhysicalIndex))) .ToArray(); + var playlistRows = _playlist + .Select(row => row with + { + IsSelected = _selectedPlaylistRowIds.Contains(row.RowId) + }) + .ToArray(); + var tabs = _tabOrder + .Select(tab => new LegacyOperatorTabState( + tab, + GetTabLabel(tab), + tab == _activeTab)) + .ToArray(); + var fixedCatalog = CreateFixedCatalogView(); + var operatorCatalog = _operatorCatalogWorkflow?.Current; + return new LegacyOperatorSnapshot( _revision, _searchText, Array.AsReadOnly(searchRows), _selectedStockIndex, Array.AsReadOnly(cutRows), - Array.AsReadOnly(_playlist.ToArray()), + Array.AsReadOnly(playlistRows), _dialog, _statusMessage, - _statusKind); + _statusKind, + Array.AsReadOnly(tabs), + _movingAverages, + fixedCatalog, + GetActiveIndustrySnapshot(), + IsThemeActive() ? _themeSnapshot : null, + IsExpertActive() ? _expertWorkflow?.Current : null, + IsTradingHaltActive() ? _tradingHaltWorkflow?.Current : null, + IsComparisonActive() ? _comparisonWorkflow?.Current : null, + _manualFinancialSnapshot, + _namedPlaylistWorkflow?.Current, + IsOverseasActive() ? _overseasWorkflow?.Current : null, + _manualListsWorkflow?.Current, + operatorCatalog?.IsOpen == true ? operatorCatalog : null, + _commandResult, + _pendingFixedSectionBatch?.CreateView()); } + private LegacyFixedCatalogView? CreateFixedCatalogView() + { + var market = GetActiveFixedMarket(); + if (market is null) + { + return null; + } + + var sections = _fixedCatalog.GetSections(market.Value) + .Select((section, index) => new LegacyFixedSectionView( + index, + section.Name, + Array.AsReadOnly(section.Actions + .Select(action => new LegacyFixedActionView( + action.Id, + action.Label, + action.Detail, + action.Available, + action.Prerequisite)) + .ToArray()))) + .ToArray(); + return new LegacyFixedCatalogView(market.Value, Array.AsReadOnly(sections)); + } + + private LegacyFixedMarket? GetActiveFixedMarket() => _activeTab switch + { + LegacyOperatorTabId.Overseas => LegacyFixedMarket.Overseas, + LegacyOperatorTabId.Exchange => LegacyFixedMarket.Exchange, + LegacyOperatorTabId.Index => LegacyFixedMarket.Index, + _ => null + }; + + private MMoneyCoderSharp.Data.IndustryMarket? GetActiveIndustryMarket() => _activeTab switch + { + LegacyOperatorTabId.KospiIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kospi, + LegacyOperatorTabId.KosdaqIndustry => MMoneyCoderSharp.Data.IndustryMarket.Kosdaq, + _ => null + }; + + private LegacyIndustrySelectionSnapshot? GetActiveIndustrySnapshot() => + GetActiveIndustryMarket() is { } market && + _industryWorkflow?.Current.Market == market + ? _industryWorkflow.Current + : null; + + private bool IsActiveIndustryReady() => GetActiveIndustrySnapshot() is not null; + + private bool IsThemeActive() => _activeTab == LegacyOperatorTabId.Theme; + + private void BeginTabActivation(LegacyOperatorTabId tabId) + { + if (tabId == LegacyOperatorTabId.Theme) + { + _themeActivationListLoaded = false; + } + else if (tabId == LegacyOperatorTabId.Expert) + { + _expertActivationListLoaded = false; + } + } + + private bool IsCurrentTabActivation( + LegacyOperatorTabId tabId, + long activationRequest) => + _activeTab == tabId && + Volatile.Read(ref _tabActivationRequest) == activationRequest; + + private bool IsComparisonActive() => _activeTab == LegacyOperatorTabId.Comparison; + + private bool IsExpertActive() => _activeTab == LegacyOperatorTabId.Expert; + + private bool IsTradingHaltActive() => _activeTab == LegacyOperatorTabId.TradingHalt; + + private bool IsOverseasActive() => _activeTab == LegacyOperatorTabId.OverseasStocks; + + private bool TryGetManualFinancial( + out LegacyManualFinancialWorkflow workflow, + out LegacyManualFinancialWorkflowSnapshot snapshot) + { + workflow = _manualFinancialWorkflow!; + snapshot = _manualFinancialSnapshot!; + return workflow is not null && snapshot is not null; + } + + private void ApplyManualFinancialResultStatus( + LegacyManualFinancialWorkflowSnapshot snapshot, + string operation) + { + _statusMessage = snapshot.Status switch + { + LegacyManualFinancialWorkflowStatus.WriteCommitted => + $"수동 재무 {operation} 작업이 완료되었습니다.", + LegacyManualFinancialWorkflowStatus.WriteRejected => + snapshot.LastMessage ?? $"수동 재무 {operation} 작업이 거부되었습니다.", + LegacyManualFinancialWorkflowStatus.ReadBackRequired => + snapshot.LastMessage ?? "저장은 완료됐지만 새로 읽기가 필요합니다.", + LegacyManualFinancialWorkflowStatus.OutcomeUnknown => + snapshot.LastMessage ?? "결과가 불명확하여 수동 재무 쓰기를 차단했습니다.", + _ => snapshot.LastMessage ?? string.Empty + }; + _statusKind = snapshot.Status switch + { + LegacyManualFinancialWorkflowStatus.WriteCommitted => + LegacyOperatorStatusKind.Information, + LegacyManualFinancialWorkflowStatus.WriteRejected => + LegacyOperatorStatusKind.Warning, + LegacyManualFinancialWorkflowStatus.ReadBackRequired or + LegacyManualFinancialWorkflowStatus.OutcomeUnknown => + LegacyOperatorStatusKind.Error, + _ => LegacyOperatorStatusKind.Neutral + }; + } + + private void ApplyManualListsStatus() + { + if (_manualListsWorkflow is null) + { + return; + } + + _statusMessage = _manualListsWorkflow.Current.StatusMessage; + _statusKind = _manualListsWorkflow.Current.StatusKind; + } + + private async Task OpenPendingFixedSectionManualAsync( + CancellationToken cancellationToken) + { + var pending = _pendingFixedSectionBatch + ?? throw new InvalidOperationException("A fixed-section batch is not active."); + var item = pending.CurrentManualItem + ?? throw new InvalidOperationException("The fixed-section batch has no pending manual item."); + var target = item.ManualTarget!.Value; + + if (target.Screen == LegacyManualListScreen.NetSell) + { + await OpenManualNetSellCoreAsync(target.Audience, cancellationToken) + .ConfigureAwait(false); + } + else + { + await OpenManualViCoreAsync(cancellationToken).ConfigureAwait(false); + } + + var manual = _manualListsWorkflow!.Current; + var view = pending.CreateView(); + var progress = + $"{pending.SectionName} 섹션 수동 입력 {view.ManualStepNumber}/{view.ManualStepCount}: {item.Action.Label}. 확인하면 다음 직계 자식으로 계속합니다."; + _statusMessage = string.IsNullOrWhiteSpace(manual.StatusMessage) + ? progress + : $"{progress} {manual.StatusMessage}"; + _statusKind = manual.StatusKind == LegacyOperatorStatusKind.Neutral + ? LegacyOperatorStatusKind.Information + : manual.StatusKind; + Touch(); + return CreateSnapshot(); + } + + private async Task ContinuePendingFixedSectionAsync( + CancellationToken cancellationToken) + { + var pending = _pendingFixedSectionBatch + ?? throw new InvalidOperationException("A fixed-section batch is not active."); + if (pending.CurrentManualItem is not null) + { + return await OpenPendingFixedSectionManualAsync(cancellationToken) + .ConfigureAwait(false); + } + + try + { + CommitFixedSectionBatch(pending.Items); + } + catch + { + _pendingFixedSectionBatch = null; + throw; + } + + _pendingFixedSectionBatch = null; + _statusMessage = + $"{pending.SectionName} 항목 {pending.Items.Count}개를 원본 순서로 추가했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + + private void CommitFixedSectionBatch(IReadOnlyList items) + { + var originalCount = _playlist.Count; + var originalSequence = _playlistSequence; + try + { + foreach (var item in items) + { + if (item.Materialized is not null) + { + AppendFixedRow(item.Materialized); + } + else if (item.ManualDraft is not null) + { + AppendManualListRow(item.ManualDraft); + } + else + { + throw new InvalidOperationException( + "A fixed-section batch item was not materialized."); + } + } + } + catch + { + if (_playlist.Count > originalCount) + { + _playlist.RemoveRange(originalCount, _playlist.Count - originalCount); + } + + _playlistSequence = originalSequence; + throw; + } + } + + private static FixedManualTarget? GetFixedManualTarget(string actionId) => actionId switch + { + "fixed-245" => new FixedManualTarget( + LegacyManualListScreen.NetSell, + S5025ManualAudience.Individual), + "fixed-246" => new FixedManualTarget( + LegacyManualListScreen.NetSell, + S5025ManualAudience.Foreign), + "fixed-247" => new FixedManualTarget( + LegacyManualListScreen.NetSell, + S5025ManualAudience.Institution), + "fixed-262" or "fixed-293" or "fixed-320" => new FixedManualTarget( + LegacyManualListScreen.Vi, + S5025ManualAudience.Individual), + _ => null + }; + + private void ApplyOperatorCatalogStatus() + { + if (_operatorCatalogWorkflow is null) + { + return; + } + + var snapshot = _operatorCatalogWorkflow.Current; + _statusMessage = snapshot.StatusMessage; + _statusKind = snapshot.StatusKind switch + { + LegacyOperatorCatalogStatusKind.Information => LegacyOperatorStatusKind.Information, + LegacyOperatorCatalogStatusKind.Warning => LegacyOperatorStatusKind.Warning, + LegacyOperatorCatalogStatusKind.Error => LegacyOperatorStatusKind.Error, + _ => LegacyOperatorStatusKind.Neutral + }; + } + + private void AppendManualListRow(LegacyManualListPlaylistDraft draft) + { + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-manual-list-{++_playlistSequence:D8}", + true, + draft.MarketText, + draft.StockName, + draft.GraphicType, + draft.Subtype, + draft.PageText, + draft.Selection.DataCode, + draft.RawCutLabel, + draft.FadeDuration, + SceneAlias: draft.SceneAlias, + PlayoutSelection: draft.Selection)); + } + + private void AppendFixedRow(LegacyMaterializedFixedAction materialized) + { + var action = materialized.Action; + var selection = materialized.Selection; + var marketText = action.Market switch + { + LegacyFixedMarket.Overseas => "해외지수", + LegacyFixedMarket.Exchange => "환율", + LegacyFixedMarket.Index => "주요지수", + _ => throw new ArgumentOutOfRangeException() + }; + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-fixed-{++_playlistSequence:D8}", + true, + marketText, + action.Label, + action.Section, + action.Detail, + "1/1", + selection.DataCode, + action.Label, + 6, + SceneAlias: action.Code, + PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + selection.DataCode))); + } + + private void AppendIndustryRow(LegacyIndustryPlaylistDraft draft, int itemCount) + { + var split = draft.Detail.IndexOf('_'); + var graphicType = split > 0 ? draft.Detail[..split] : draft.Detail; + var subtype = split > 0 && split < draft.Detail.Length - 1 + ? draft.Detail[(split + 1)..] + : string.Empty; + var pageCount = draft.PageSize > 0 + ? Math.Clamp( + (int)Math.Ceiling(itemCount / (double)draft.PageSize), + 1, + 20) + : 1; + var marketText = draft.Market == MMoneyCoderSharp.Data.IndustryMarket.Kospi + ? "업종_코스피" + : "업종_코스닥"; + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-industry-{++_playlistSequence:D8}", + draft.IsEnabled, + marketText, + draft.Title, + graphicType, + subtype, + $"1/{pageCount}", + draft.Selection.DataCode, + draft.Detail, + draft.FadeDuration, + SceneAlias: draft.SceneAlias, + PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( + draft.Selection.GroupCode, + draft.Selection.Subject, + draft.Selection.GraphicType, + draft.Selection.Subtype, + draft.Selection.DataCode))); + } + + private void AppendOverseasRow(LegacyOverseasPlaylistDraft draft) + { + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-overseas-{++_playlistSequence:D8}", + draft.IsEnabled, + draft.Selection.GroupCode, + draft.Title, + draft.Selection.GraphicType, + draft.Selection.Subtype, + draft.InitialPageText, + draft.Selection.DataCode, + draft.Detail, + draft.FadeDuration, + SceneAlias: draft.SceneAlias, + PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( + draft.Selection.GroupCode, + draft.Selection.Subject, + draft.Selection.GraphicType, + draft.Selection.Subtype, + draft.Selection.DataCode))); + } + + private void AppendThemeRow(LegacyThemePlaylistDraft draft) + { + var separator = draft.Detail.IndexOf(" · ", StringComparison.Ordinal); + var graphicType = separator > 0 ? draft.Detail[..separator] : draft.Detail; + var subtype = separator > 0 ? draft.Detail[(separator + 3)..] : string.Empty; + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-theme-{++_playlistSequence:D8}", + true, + draft.Selection.GroupCode == "PAGED_THEME" ? "테마" : "테마_NXT", + draft.Title, + graphicType, + subtype, + draft.PagePlan.InitialPageText, + draft.Selection.DataCode, + draft.Detail, + 6, + SceneAlias: draft.Code, + PlayoutSelection: new MBN_STOCK_WEBVIEW.Core.Playout.Scenes.LegacySceneSelection( + draft.Selection.GroupCode, + draft.Selection.Subject, + draft.Selection.GraphicType, + draft.Selection.Subtype, + draft.Selection.DataCode))); + } + + private void AppendComparisonRow(LegacyGenericPlaylistDraft draft) + { + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-comparison-{++_playlistSequence:D8}", + draft.IsEnabled, + "비교", + draft.Title, + draft.BuilderKey, + draft.Detail, + "1/1", + draft.Selection.DataCode, + draft.Source, + draft.FadeDuration, + SceneAlias: draft.CutCode, + PlayoutSelection: draft.Selection)); + } + + private void AppendExpertRow(LegacyExpertPlaylistDraft draft) + { + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-expert-{++_playlistSequence:D8}", + draft.IsEnabled, + "전문가", + draft.Title, + "5단 표그래프", + "현재가", + draft.PageText, + draft.ExpertCode, + draft.Detail, + draft.FadeDuration, + SceneAlias: draft.CutCode, + PlayoutSelection: draft.Selection)); + } + + private void AppendTradingHaltRow(LegacyTradingHaltPlaylistDraft draft) + { + var isCandle = draft.CandlePeriodDays.HasValue; + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-halt-{++_playlistSequence:D8}", + draft.IsEnabled, + draft.Market == "kospi" ? "코스피" : "코스닥", + draft.Title, + isCandle ? "캔들그래프" : "1열판기본", + isCandle ? "거래정지_120일" : "현재가", + draft.PageText, + draft.StockCode, + draft.Detail, + draft.FadeDuration, + SceneAlias: draft.CutCode, + PlayoutSelection: draft.Selection)); + } + + private void AppendManualFinancialRow(LegacyManualFinancialPlaylistDraft draft) + { + _playlist.Add(new LegacyOperatorPlaylistRow( + $"legacy-manual-financial-{++_playlistSequence:D8}", + true, + draft.Market, + draft.Title, + draft.Detail, + draft.CutSelection.Selection.GraphicType, + "1/1", + draft.StockCode, + draft.Detail, + 6, + SceneAlias: draft.Code, + PlayoutSelection: draft.CutSelection.Selection)); + } + + private static string GetTabLabel(LegacyOperatorTabId tabId) => tabId switch + { + LegacyOperatorTabId.Overseas => "해외", + LegacyOperatorTabId.Exchange => "환율", + LegacyOperatorTabId.Index => "지수", + LegacyOperatorTabId.KospiIndustry => "코스피", + LegacyOperatorTabId.KosdaqIndustry => "코스닥", + LegacyOperatorTabId.Comparison => "비교", + LegacyOperatorTabId.Theme => "테마", + LegacyOperatorTabId.OverseasStocks => "해외종목", + LegacyOperatorTabId.Expert => "전문가", + LegacyOperatorTabId.TradingHalt => "정지", + _ => throw new ArgumentOutOfRangeException(nameof(tabId), tabId, null) + }; + private static bool IsCutIndex(int physicalIndex) => physicalIndex >= 0 && physicalIndex < LegacyStockCutCatalog.Rows.Count; + private int FindPlaylistIndex(string rowId) => _playlist.FindIndex(row => + string.Equals(row.RowId, rowId, StringComparison.Ordinal)); + + private void AddPlaylistRange(int firstIndex, int lastIndex) + { + var first = Math.Max(0, Math.Min(firstIndex, lastIndex)); + var last = Math.Min(_playlist.Count - 1, Math.Max(firstIndex, lastIndex)); + for (var index = first; index <= last; index++) + { + _selectedPlaylistRowIds.Add(_playlist[index].RowId); + } + } + + private void ResetPlaylistSelection() + { + _selectedPlaylistRowIds.Clear(); + _activePlaylistRowId = null; + _playlistCursorIndex = 0; + _playlistPriorCursorIndex = 0; + } + + private void CompleteCommand(string command, string targetId, bool succeeded) + { + _commandResult = new LegacyOperatorCommandResult( + checked(++_commandSequence), + command, + targetId, + succeeded); + } + + private readonly record struct FixedManualTarget( + LegacyManualListScreen Screen, + S5025ManualAudience Audience); + + private sealed class PendingFixedSectionItem + { + internal PendingFixedSectionItem( + LegacyFixedAction action, + LegacyMaterializedFixedAction materialized) + { + Action = action; + Materialized = materialized; + } + + internal PendingFixedSectionItem( + LegacyFixedAction action, + FixedManualTarget manualTarget) + { + Action = action; + ManualTarget = manualTarget; + } + + internal LegacyFixedAction Action { get; } + + internal LegacyMaterializedFixedAction? Materialized { get; } + + internal FixedManualTarget? ManualTarget { get; } + + internal LegacyManualListPlaylistDraft? ManualDraft { get; private set; } + + internal bool IsStaged => Materialized is not null || ManualDraft is not null; + + internal void StageManual(LegacyManualListPlaylistDraft draft) + { + ArgumentNullException.ThrowIfNull(draft); + if (ManualTarget is null || ManualDraft is not null) + { + throw new InvalidOperationException( + "The fixed-section manual item cannot be staged in its current state."); + } + + ManualDraft = draft; + } + } + + private sealed class PendingFixedSectionBatch + { + internal PendingFixedSectionBatch( + LegacyFixedMarket market, + int sectionIndex, + string sectionName, + IReadOnlyList items, + int manualCount) + { + Market = market; + SectionIndex = sectionIndex; + SectionName = sectionName; + Items = items; + ManualCount = manualCount; + } + + internal LegacyFixedMarket Market { get; } + + internal int SectionIndex { get; } + + internal string SectionName { get; } + + internal IReadOnlyList Items { get; } + + internal int ManualCount { get; } + + internal PendingFixedSectionItem? CurrentManualItem => Items.FirstOrDefault(item => + item.ManualTarget is not null && item.ManualDraft is null); + + internal bool IsCurrentManual( + LegacyManualListScreen screen, + S5025ManualAudience audience) + { + var target = CurrentManualItem?.ManualTarget; + return target is not null && target.Value.Screen == screen && + (screen == LegacyManualListScreen.Vi || target.Value.Audience == audience); + } + + internal void StageCurrentManual(LegacyManualListPlaylistDraft draft) + { + var current = CurrentManualItem + ?? throw new InvalidOperationException( + "The fixed-section batch has no pending manual item."); + current.StageManual(draft); + } + + internal LegacyFixedSectionBatchView CreateView() + { + var current = CurrentManualItem + ?? throw new InvalidOperationException( + "A completed fixed-section batch cannot be projected as pending."); + var manualStep = Items + .Where(item => item.ManualTarget is not null) + .TakeWhile(item => !ReferenceEquals(item, current)) + .Count() + 1; + return new LegacyFixedSectionBatchView( + SectionIndex, + SectionName, + Items.Count, + Items.Count(item => item.IsStaged), + manualStep, + ManualCount, + current.Action.Id, + current.Action.Label); + } + } + private void Touch() => _revision++; } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs new file mode 100644 index 0000000..0ce0109 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOperatorPlayoutModels.cs @@ -0,0 +1,56 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyOperatorPlayoutPhase +{ + Idle, + Prepared, + Program +} + +public enum LegacyOperatorPlayoutCommand +{ + Prepare, + TakeIn, + Next, + TakeOut +} + +public sealed record LegacyOperatorPlayoutRequest( + IReadOnlyList Playlist, + int SelectedIndexZeroBased); + +public sealed record LegacyOperatorPlayoutSnapshot( + PlayoutMode Mode, + PlayoutConnectionState ConnectionState, + LegacyOperatorPlayoutPhase Phase, + bool IsProcessRunning, + bool IsConnected, + bool IsCommandAvailable, + bool LiveTakeInAllowed, + bool IsPlayCompletionPending, + bool OutcomeUnknown, + string? PreparedCode, + string? OnAirCode, + int CurrentCueIndexZeroBased, + string? CurrentEntryId, + string? BuilderKey, + int PageIndexZeroBased, + int PageCount, + int PageSize, + int ItemCount, + int CurrentPageItemCount, + bool IsLastPage, + LegacyWorkflowNextKind NextKind, + bool IsBusy, + bool RefreshActive, + int RefreshCompletedCount, + int? RefreshMaximumCount, + bool RefreshLimitReached, + int FadeDuration, + bool BackgroundEnabled, + string BackgroundFileName, + bool CanChangeBackground, + string Message); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasActionCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasActionCatalog.cs new file mode 100644 index 0000000..a0a903a --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasActionCatalog.cs @@ -0,0 +1,283 @@ +using System.Collections.ObjectModel; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyOverseasActionSource +{ + FixedIndex, + Industry, + Stock +} + +public enum LegacyOverseasActionKind +{ + Current, + Candle +} + +public sealed class LegacyOverseasFixedIndexTarget +{ + internal LegacyOverseasFixedIndexTarget( + string targetId, + string displayName, + string inputName, + string symbol, + string nationCode) + { + TargetId = targetId; + DisplayName = displayName; + InputName = inputName; + Symbol = symbol; + NationCode = nationCode; + } + + public string TargetId { get; } + + public string DisplayName { get; } + + public string InputName { get; } + + public string Symbol { get; } + + public string NationCode { get; } + + public string ForeignDomesticCode => "0"; +} + +public sealed class LegacyOverseasActionDefinition +{ + internal LegacyOverseasActionDefinition( + string actionId, + string label, + LegacyOverseasActionSource source, + LegacyOverseasActionKind kind, + string builderKey, + string sceneAlias, + int? periodDays) + { + ActionId = actionId; + Label = label; + Source = source; + Kind = kind; + BuilderKey = builderKey; + SceneAlias = sceneAlias; + Aliases = Array.AsReadOnly([sceneAlias]); + PeriodDays = periodDays; + } + + public string ActionId { get; } + + public string Label { get; } + + public LegacyOverseasActionSource Source { get; } + + public LegacyOverseasActionKind Kind { get; } + + public string BuilderKey { get; } + + public string SceneAlias { get; } + + public IReadOnlyList Aliases { get; } + + public int PageSize => 0; + + public int? PeriodDays { get; } + + public bool SupportsMovingAverages => Kind == LegacyOverseasActionKind.Candle; +} + +/// +/// Closed UC5 metadata. The original control exposed ten fixed world indices and twelve +/// buttons: five index candles, one US-industry current plate, and six US/TW stock actions. +/// Scene identities never come from Web input. +/// +public static class LegacyOverseasActionCatalog +{ + public const int FixedIndexTargetCount = 10; + public const int ActionCount = 12; + + private static readonly IReadOnlyList RegisteredTargets = + Array.AsReadOnly( + [ + Target("dow-jones", "다우존스", "DJI@DJI", "US"), + Target("nasdaq", "나스닥", "NAS@IXIC", "US"), + Target("sp500", "S&P500", "SPI@SPX", "US"), + Target("germany-dax-30", "독일 DAX 30", "XTR@DAX30", "DE"), + Target("united-kingdom-ftse-100", "영국 FTSE 100", "LNS@FTSE100", "GB"), + Target("france-cac-40", "프랑스 CAC 40", "PAS@CAC40", "FR"), + Target("japan-nikkei", "일본 니케이", "NII@NI225", "JP"), + Target("china-shanghai-composite", "중국 상해종합", "SHS@000001", "CN"), + Target("hong-kong-hang-seng", "홍콩 항셍", "HSI@HSI", "HK"), + Target("taiwan-weighted", "대만 가권", "TWS@TI01", "TW") + ]); + + private static readonly IReadOnlyList RegisteredActions = + Array.AsReadOnly(CreateActions()); + + private static readonly IReadOnlyDictionary + TargetsById = new ReadOnlyDictionary( + RegisteredTargets.ToDictionary(target => target.TargetId, StringComparer.Ordinal)); + + private static readonly IReadOnlyDictionary + ActionsById = new ReadOnlyDictionary( + RegisteredActions.ToDictionary(action => action.ActionId, StringComparer.Ordinal)); + + static LegacyOverseasActionCatalog() + { + ValidateTargets(); + ValidateActions(); + } + + public static IReadOnlyList FixedIndexTargets => + RegisteredTargets; + + public static IReadOnlyList Actions => RegisteredActions; + + public static IReadOnlyList GetActions( + LegacyOverseasActionSource source) + { + if (!Enum.IsDefined(source)) + { + throw new ArgumentOutOfRangeException(nameof(source), source, "Unknown overseas source."); + } + + return Array.AsReadOnly( + RegisteredActions.Where(action => action.Source == source).ToArray()); + } + + public static LegacyOverseasFixedIndexTarget? FindFixedIndexTarget(string? targetId) => + targetId is not null && TargetsById.TryGetValue(targetId, out var target) + ? target + : null; + + public static LegacyOverseasFixedIndexTarget GetFixedIndexTarget(string targetId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + return TargetsById.TryGetValue(targetId, out var target) + ? target + : throw new KeyNotFoundException("The overseas fixed-index target is not registered."); + } + + public static LegacyOverseasActionDefinition? FindAction(string? actionId) => + actionId is not null && ActionsById.TryGetValue(actionId, out var action) + ? action + : null; + + public static LegacyOverseasActionDefinition GetAction(string actionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(actionId); + return ActionsById.TryGetValue(actionId, out var action) + ? action + : throw new KeyNotFoundException("The overseas action is not registered."); + } + + private static LegacyOverseasFixedIndexTarget Target( + string targetId, + string inputName, + string symbol, + string nationCode) => + new(targetId, inputName, inputName, symbol, nationCode); + + private static LegacyOverseasActionDefinition[] CreateActions() => + [ + Candle("index-candle-5d", "해외지수 캔들 5일", LegacyOverseasActionSource.FixedIndex, 5, "8061"), + Candle("index-candle-20d", "해외지수 캔들 20일", LegacyOverseasActionSource.FixedIndex, 20, "8040"), + Candle("index-candle-60d", "해외지수 캔들 60일", LegacyOverseasActionSource.FixedIndex, 60, "8046"), + Candle("index-candle-120d", "해외지수 캔들 120일", LegacyOverseasActionSource.FixedIndex, 120, "8051"), + Candle("index-candle-240d", "해외지수 캔들 240일", LegacyOverseasActionSource.FixedIndex, 240, "8056"), + new( + "industry-current", + "미국 해외업종 현재가 1열판", + LegacyOverseasActionSource.Industry, + LegacyOverseasActionKind.Current, + "s5001", + "5001", + periodDays: null), + new( + "stock-current", + "해외종목 현재가 1열판", + LegacyOverseasActionSource.Stock, + LegacyOverseasActionKind.Current, + "s5001", + "5001", + periodDays: null), + Candle("stock-candle-5d", "해외종목 캔들 5일", LegacyOverseasActionSource.Stock, 5, "8061"), + Candle("stock-candle-20d", "해외종목 캔들 20일", LegacyOverseasActionSource.Stock, 20, "8040"), + Candle("stock-candle-60d", "해외종목 캔들 60일", LegacyOverseasActionSource.Stock, 60, "8046"), + Candle("stock-candle-120d", "해외종목 캔들 120일", LegacyOverseasActionSource.Stock, 120, "8051"), + Candle("stock-candle-240d", "해외종목 캔들 240일", LegacyOverseasActionSource.Stock, 240, "8056") + ]; + + private static LegacyOverseasActionDefinition Candle( + string actionId, + string label, + LegacyOverseasActionSource source, + int periodDays, + string sceneAlias) => + new( + actionId, + label, + source, + LegacyOverseasActionKind.Candle, + "s8010", + sceneAlias, + periodDays); + + private static void ValidateTargets() + { + if (RegisteredTargets.Count != FixedIndexTargetCount || + RegisteredTargets.Select(target => target.TargetId) + .Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount || + RegisteredTargets.Select(target => target.DisplayName) + .Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount || + RegisteredTargets.Select(target => target.Symbol) + .Distinct(StringComparer.Ordinal).Count() != FixedIndexTargetCount || + RegisteredTargets.Any(target => + !IsSafeId(target.TargetId) || + !IsSafeCanonicalText(target.DisplayName, 200) || + !string.Equals(target.DisplayName, target.InputName, StringComparison.Ordinal) || + !IsSafeSymbol(target.Symbol) || + target.NationCode.Length != 2 || + target.NationCode.Any(character => character is < 'A' or > 'Z'))) + { + throw new InvalidOperationException("The legacy overseas fixed-index catalog is invalid."); + } + } + + private static void ValidateActions() + { + if (RegisteredActions.Count != ActionCount || + RegisteredActions.Select(action => action.ActionId) + .Distinct(StringComparer.Ordinal).Count() != ActionCount || + RegisteredActions.Any(action => !IsSafeId(action.ActionId)) || + RegisteredActions.Any(action => action.PageSize != 0) || + RegisteredActions.Any(action => + action.Kind == LegacyOverseasActionKind.Candle != action.PeriodDays.HasValue) || + RegisteredActions.Any(action => + action.Source == LegacyOverseasActionSource.Industry && + action.Kind != LegacyOverseasActionKind.Current) || + RegisteredActions.Any(action => + !LegacySceneCatalog.FindByCutAlias(action.SceneAlias).Any(scene => + string.Equals(scene.BuilderKey, action.BuilderKey, StringComparison.Ordinal) && + (int)scene.PageSize == action.PageSize))) + { + throw new InvalidOperationException("The legacy overseas action catalog is invalid."); + } + } + + private static bool IsSafeId(string value) => + value.Length is > 0 and <= 64 && + value.All(character => char.IsAsciiLetterOrDigit(character) || character == '-'); + + private static bool IsSafeCanonicalText(string value, int maximumLength) => + value.Length is > 0 && + value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + !value.Any(character => char.IsControl(character) || char.IsSurrogate(character)); + + private static bool IsSafeSymbol(string value) => + IsSafeCanonicalText(value, 64) && + value.All(character => + char.IsAsciiLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-'); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs new file mode 100644 index 0000000..6d8a20f --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyOverseasSelectionWorkflow.cs @@ -0,0 +1,897 @@ +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyOverseasSearchStatus +{ + Idle, + Loading, + Ready, + Error +} + +public readonly record struct LegacyOverseasMovingAverageSelection(bool Ma5, bool Ma20); + +public sealed class LegacyOverseasIndustrySearchRow +{ + internal LegacyOverseasIndustrySearchRow( + string selectionId, + OverseasIndustryIndexItem identity) + { + SelectionId = selectionId; + Identity = identity; + } + + public string SelectionId { get; } + + public string KoreanName => Identity.KoreanName; + + public string InputName => Identity.InputName; + + public string Symbol => Identity.Symbol; + + public string NationCode => "US"; + + public string ForeignDomesticCode => "0"; + + internal OverseasIndustryIndexItem Identity { get; } +} + +public sealed class LegacyOverseasStockSearchRow +{ + internal LegacyOverseasStockSearchRow( + string selectionId, + WorldStockSearchItem identity) + { + SelectionId = selectionId; + Identity = identity; + } + + public string SelectionId { get; } + + public string InputName => Identity.InputName; + + public string Symbol => Identity.Symbol; + + public string NationCode => Identity.NationCode; + + public string ForeignDomesticCode => "1"; + + internal WorldStockSearchItem Identity { get; } +} + +public sealed class LegacyOverseasIndustrySearchSnapshot +{ + internal LegacyOverseasIndustrySearchSnapshot( + LegacyOverseasSearchStatus status, + string query, + DateTimeOffset? retrievedAt, + IReadOnlyList results, + bool isTruncated, + LegacyOverseasIndustrySearchRow? selected, + string error) + { + Status = status; + Query = query; + RetrievedAt = retrievedAt; + Results = results; + IsTruncated = isTruncated; + Selected = selected; + Error = error; + } + + public LegacyOverseasSearchStatus Status { get; } + + public string Query { get; } + + public DateTimeOffset? RetrievedAt { get; } + + public IReadOnlyList Results { get; } + + public int ResultCount => Results.Count; + + public bool IsTruncated { get; } + + public LegacyOverseasIndustrySearchRow? Selected { get; } + + public string? SelectedId => Selected?.SelectionId; + + public string Error { get; } +} + +public sealed class LegacyOverseasStockSearchSnapshot +{ + internal LegacyOverseasStockSearchSnapshot( + LegacyOverseasSearchStatus status, + string query, + DateTimeOffset? retrievedAt, + IReadOnlyList results, + bool isTruncated, + LegacyOverseasStockSearchRow? selected, + string error) + { + Status = status; + Query = query; + RetrievedAt = retrievedAt; + Results = results; + IsTruncated = isTruncated; + Selected = selected; + Error = error; + } + + public LegacyOverseasSearchStatus Status { get; } + + public string Query { get; } + + public DateTimeOffset? RetrievedAt { get; } + + public IReadOnlyList Results { get; } + + public int ResultCount => Results.Count; + + public bool IsTruncated { get; } + + public LegacyOverseasStockSearchRow? Selected { get; } + + public string? SelectedId => Selected?.SelectionId; + + public string Error { get; } +} + +public sealed class LegacyOverseasWorkflowSnapshot +{ + internal LegacyOverseasWorkflowSnapshot( + long revision, + IReadOnlyList fixedIndexTargets, + LegacyOverseasFixedIndexTarget? selectedFixedIndex, + LegacyOverseasIndustrySearchSnapshot industry, + LegacyOverseasStockSearchSnapshot stock, + LegacyOverseasMovingAverageSelection movingAverages, + IReadOnlyList actions) + { + Revision = revision; + FixedIndexTargets = fixedIndexTargets; + SelectedFixedIndex = selectedFixedIndex; + Industry = industry; + Stock = stock; + MovingAverages = movingAverages; + Actions = actions; + } + + public long Revision { get; } + + public IReadOnlyList FixedIndexTargets { get; } + + public LegacyOverseasFixedIndexTarget? SelectedFixedIndex { get; } + + public LegacyOverseasIndustrySearchSnapshot Industry { get; } + + public LegacyOverseasStockSearchSnapshot Stock { get; } + + public LegacyOverseasMovingAverageSelection MovingAverages { get; } + + public IReadOnlyList Actions { get; } +} + +public sealed record LegacyOverseasPlaylistSelection( + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string DataCode); + +public sealed record LegacyOverseasPlaylistDraft( + string ActionId, + string BuilderKey, + string SceneAlias, + IReadOnlyList Aliases, + int PageSize, + string InitialPageText, + string Title, + string Detail, + string Category, + string Market, + LegacyOverseasActionSource Source, + LegacyOverseasPlaylistSelection Selection, + LegacyOverseasMovingAverageSelection MovingAverages, + bool IsEnabled, + int FadeDuration); + +public sealed class LegacyOverseasWorkflowException : Exception +{ + public LegacyOverseasWorkflowException(string message) + : base(message) + { + } +} + +/// +/// C# authority for the UC5 overseas selector. Search services return identities, while Web +/// receives generation-bound selection ids and closed action ids only. A slower response can +/// never replace the state established by a newer request of the same source. +/// +public sealed class LegacyOverseasSelectionWorkflow +{ + public const int DefaultMaximumResults = 100; + public const int MaximumResults = 500; + public const int MaximumQueryLength = 64; + + private const int DefaultFadeDuration = 6; + private const string InitialPageText = "1/1"; + private const string IndustryError = "미국 해외업종 검색 결과를 안전하게 확인하지 못했습니다."; + private const string StockError = "미국·대만 해외종목 검색 결과를 안전하게 확인하지 못했습니다."; + + private static readonly IReadOnlyList NoIndustryResults = + Array.AsReadOnly(Array.Empty()); + + private static readonly IReadOnlyList NoStockResults = + Array.AsReadOnly(Array.Empty()); + + private readonly IOverseasIndustryIndexSearchService _industrySearchService; + private readonly IWorldStockSearchService _stockSearchService; + private readonly object _sync = new(); + + private LegacyOverseasFixedIndexTarget? _selectedFixedIndex; + private LegacyOverseasSearchStatus _industryStatus; + private string _industryQuery = string.Empty; + private DateTimeOffset? _industryRetrievedAt; + private IReadOnlyList _industryResults = NoIndustryResults; + private bool _industryIsTruncated; + private LegacyOverseasIndustrySearchRow? _selectedIndustry; + private string _industryError = string.Empty; + private long _industryGeneration; + private LegacyOverseasSearchStatus _stockStatus; + private string _stockQuery = string.Empty; + private DateTimeOffset? _stockRetrievedAt; + private IReadOnlyList _stockResults = NoStockResults; + private bool _stockIsTruncated; + private LegacyOverseasStockSearchRow? _selectedStock; + private string _stockError = string.Empty; + private long _stockGeneration; + private LegacyOverseasMovingAverageSelection _movingAverages; + private long _revision; + + public LegacyOverseasSelectionWorkflow( + IOverseasIndustryIndexSearchService industrySearchService, + IWorldStockSearchService stockSearchService) + { + _industrySearchService = industrySearchService ?? + throw new ArgumentNullException(nameof(industrySearchService)); + _stockSearchService = stockSearchService ?? + throw new ArgumentNullException(nameof(stockSearchService)); + } + + public LegacyOverseasWorkflowSnapshot Current + { + get + { + lock (_sync) + { + return CreateSnapshot(); + } + } + } + + public LegacyOverseasWorkflowSnapshot SelectFixedIndex(string targetId) + { + var target = LegacyOverseasActionCatalog.GetFixedIndexTarget(targetId); + lock (_sync) + { + if (!ReferenceEquals(_selectedFixedIndex, target)) + { + _selectedFixedIndex = target; + Touch(); + } + + return CreateSnapshot(); + } + } + + public async Task SearchIndustriesAsync( + string query, + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = NormalizeQuery(query); + ValidateMaximumResults(maximumResults); + cancellationToken.ThrowIfCancellationRequested(); + + long generation; + lock (_sync) + { + generation = checked(++_industryGeneration); + _industryStatus = LegacyOverseasSearchStatus.Loading; + _industryQuery = normalizedQuery; + _industryRetrievedAt = null; + _industryResults = NoIndustryResults; + _industryIsTruncated = false; + _selectedIndustry = null; + _industryError = string.Empty; + Touch(); + } + + try + { + var result = await _industrySearchService.SearchAsync( + normalizedQuery, + maximumResults, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + lock (_sync) + { + if (generation != _industryGeneration) + { + return CreateSnapshot(); + } + } + + var validated = ValidateIndustryResult( + result, + normalizedQuery, + maximumResults, + generation); + lock (_sync) + { + if (generation != _industryGeneration) + { + return CreateSnapshot(); + } + + _industryStatus = LegacyOverseasSearchStatus.Ready; + _industryRetrievedAt = result.RetrievedAt; + _industryResults = validated; + _industryIsTruncated = result.IsTruncated; + _industryError = string.Empty; + Touch(); + return CreateSnapshot(); + } + } + catch (OperationCanceledException) + { + lock (_sync) + { + if (generation == _industryGeneration) + { + _industryStatus = LegacyOverseasSearchStatus.Idle; + _industryError = string.Empty; + Touch(); + } + } + + throw; + } + catch + { + lock (_sync) + { + if (generation != _industryGeneration) + { + return CreateSnapshot(); + } + + _industryStatus = LegacyOverseasSearchStatus.Error; + _industryRetrievedAt = null; + _industryResults = NoIndustryResults; + _industryIsTruncated = false; + _selectedIndustry = null; + _industryError = IndustryError; + Touch(); + } + + throw; + } + } + + public async Task SearchStocksAsync( + string query, + int maximumResults = DefaultMaximumResults, + CancellationToken cancellationToken = default) + { + var normalizedQuery = NormalizeQuery(query); + ValidateMaximumResults(maximumResults); + cancellationToken.ThrowIfCancellationRequested(); + + long generation; + lock (_sync) + { + generation = checked(++_stockGeneration); + _stockStatus = LegacyOverseasSearchStatus.Loading; + _stockQuery = normalizedQuery; + _stockRetrievedAt = null; + _stockResults = NoStockResults; + _stockIsTruncated = false; + _selectedStock = null; + _stockError = string.Empty; + Touch(); + } + + try + { + var result = await _stockSearchService.SearchAsync( + normalizedQuery, + maximumResults, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + lock (_sync) + { + if (generation != _stockGeneration) + { + return CreateSnapshot(); + } + } + + var validated = ValidateStockResult( + result, + normalizedQuery, + maximumResults, + generation); + lock (_sync) + { + if (generation != _stockGeneration) + { + return CreateSnapshot(); + } + + _stockStatus = LegacyOverseasSearchStatus.Ready; + _stockRetrievedAt = result.RetrievedAt; + _stockResults = validated; + _stockIsTruncated = result.IsTruncated; + _stockError = string.Empty; + Touch(); + return CreateSnapshot(); + } + } + catch (OperationCanceledException) + { + lock (_sync) + { + if (generation == _stockGeneration) + { + _stockStatus = LegacyOverseasSearchStatus.Idle; + _stockError = string.Empty; + Touch(); + } + } + + throw; + } + catch + { + lock (_sync) + { + if (generation != _stockGeneration) + { + return CreateSnapshot(); + } + + _stockStatus = LegacyOverseasSearchStatus.Error; + _stockRetrievedAt = null; + _stockResults = NoStockResults; + _stockIsTruncated = false; + _selectedStock = null; + _stockError = StockError; + Touch(); + } + + throw; + } + } + + public LegacyOverseasWorkflowSnapshot SelectIndustry(string selectionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(selectionId); + lock (_sync) + { + var selected = _industryResults.FirstOrDefault(row => + string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException("The overseas-industry result is no longer current."); + if (!ReferenceEquals(_selectedIndustry, selected)) + { + _selectedIndustry = selected; + Touch(); + } + + return CreateSnapshot(); + } + } + + public LegacyOverseasWorkflowSnapshot SelectStock(string selectionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(selectionId); + lock (_sync) + { + var selected = _stockResults.FirstOrDefault(row => + string.Equals(row.SelectionId, selectionId, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException("The overseas-stock result is no longer current."); + if (!ReferenceEquals(_selectedStock, selected)) + { + _selectedStock = selected; + Touch(); + } + + return CreateSnapshot(); + } + } + + public LegacyOverseasWorkflowSnapshot ClearFixedIndexSelection() + { + lock (_sync) + { + if (_selectedFixedIndex is not null) + { + _selectedFixedIndex = null; + Touch(); + } + + return CreateSnapshot(); + } + } + + public LegacyOverseasWorkflowSnapshot ClearIndustrySelection() + { + lock (_sync) + { + if (_selectedIndustry is not null) + { + _selectedIndustry = null; + Touch(); + } + + return CreateSnapshot(); + } + } + + public LegacyOverseasWorkflowSnapshot ClearStockSelection() + { + lock (_sync) + { + if (_selectedStock is not null) + { + _selectedStock = null; + Touch(); + } + + return CreateSnapshot(); + } + } + + public LegacyOverseasWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20) + { + var value = new LegacyOverseasMovingAverageSelection(ma5, ma20); + lock (_sync) + { + if (_movingAverages != value) + { + _movingAverages = value; + Touch(); + } + + return CreateSnapshot(); + } + } + + /// + /// Builds all playlist and scene-selection fields from current native state. The caller can + /// select only a closed action id; it cannot supply aliases, data-code identities, paging, + /// moving-average text, or fade values. + /// + public LegacyOverseasPlaylistDraft MaterializePlaylistDraft(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + var action = LegacyOverseasActionCatalog.FindAction(actionId) + ?? throw new LegacyOverseasWorkflowException("The overseas action is not registered."); + + lock (_sync) + { + return action.Source switch + { + LegacyOverseasActionSource.FixedIndex => MaterializeFixedIndex(action), + LegacyOverseasActionSource.Industry => MaterializeIndustry(action), + LegacyOverseasActionSource.Stock => MaterializeStock(action), + _ => throw new LegacyOverseasWorkflowException("The overseas action source is invalid.") + }; + } + } + + private LegacyOverseasPlaylistDraft MaterializeFixedIndex( + LegacyOverseasActionDefinition action) + { + var target = _selectedFixedIndex ?? + throw new LegacyOverseasWorkflowException("Select a fixed overseas index first."); + var movingAverages = MovingAveragesFor(action); + return CreateDraft( + action, + $"{target.DisplayName} 캔들 {action.PeriodDays}일", + "chart", + new LegacyOverseasPlaylistSelection( + "FOREIGN_INDEX", + target.InputName, + MovingAverageText(movingAverages), + "PRICE", + $"{target.Symbol}|{target.NationCode}|{target.ForeignDomesticCode}"), + movingAverages); + } + + private LegacyOverseasPlaylistDraft MaterializeIndustry( + LegacyOverseasActionDefinition action) + { + var industry = _selectedIndustry ?? + throw new LegacyOverseasWorkflowException("Select a US overseas industry first."); + return CreateDraft( + action, + industry.KoreanName, + "plate", + new LegacyOverseasPlaylistSelection( + "FOREIGN_INDUSTRY", + industry.KoreanName, + "1열판기본", + "CURRENT", + $"{industry.InputName}|{industry.Symbol}|{industry.NationCode}|{industry.ForeignDomesticCode}"), + default); + } + + private LegacyOverseasPlaylistDraft MaterializeStock( + LegacyOverseasActionDefinition action) + { + var stock = _selectedStock ?? + throw new LegacyOverseasWorkflowException("Select a US/TW overseas stock first."); + var movingAverages = MovingAveragesFor(action); + var current = action.Kind == LegacyOverseasActionKind.Current; + return CreateDraft( + action, + stock.InputName, + current ? "stock" : "chart", + new LegacyOverseasPlaylistSelection( + "FOREIGN_STOCK", + stock.InputName, + current ? "1열판기본" : MovingAverageText(movingAverages), + current ? "CURRENT" : "PRICE", + $"{stock.Symbol}|{stock.NationCode}|{stock.ForeignDomesticCode}"), + movingAverages); + } + + private static LegacyOverseasPlaylistDraft CreateDraft( + LegacyOverseasActionDefinition action, + string title, + string category, + LegacyOverseasPlaylistSelection selection, + LegacyOverseasMovingAverageSelection movingAverages) => + new( + action.ActionId, + action.BuilderKey, + action.SceneAlias, + action.Aliases, + action.PageSize, + InitialPageText, + title, + action.Label, + category, + "overseas", + action.Source, + selection, + movingAverages, + IsEnabled: true, + FadeDuration: DefaultFadeDuration); + + private LegacyOverseasMovingAverageSelection MovingAveragesFor( + LegacyOverseasActionDefinition action) => + action.SupportsMovingAverages ? _movingAverages : default; + + private static string MovingAverageText(LegacyOverseasMovingAverageSelection selection) => + selection switch + { + { Ma5: true, Ma20: true } => "MA5,MA20", + { Ma5: true } => "MA5", + { Ma20: true } => "MA20", + _ => string.Empty + }; + + private LegacyOverseasWorkflowSnapshot CreateSnapshot() => + new( + _revision, + LegacyOverseasActionCatalog.FixedIndexTargets, + _selectedFixedIndex, + new LegacyOverseasIndustrySearchSnapshot( + _industryStatus, + _industryQuery, + _industryRetrievedAt, + _industryResults, + _industryIsTruncated, + _selectedIndustry, + _industryError), + new LegacyOverseasStockSearchSnapshot( + _stockStatus, + _stockQuery, + _stockRetrievedAt, + _stockResults, + _stockIsTruncated, + _selectedStock, + _stockError), + _movingAverages, + LegacyOverseasActionCatalog.Actions); + + private static IReadOnlyList ValidateIndustryResult( + OverseasIndustryIndexSearchResult? result, + string expectedQuery, + int maximumResults, + long generation) + { + if (result is null || result.Items is null || result.RetrievedAt == default || + !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal)) + { + throw InvalidData("industry search envelope"); + } + + var items = result.Items.ToArray(); + if (items.Length > maximumResults) + { + throw InvalidData("industry result bound"); + } + + var koreanNames = new HashSet(StringComparer.Ordinal); + var inputNames = new HashSet(StringComparer.Ordinal); + var symbols = new HashSet(StringComparer.Ordinal); + for (var index = 0; index < items.Length; index++) + { + var item = items[index] ?? throw InvalidData("industry identity"); + if (!IsSafeName(item.KoreanName) || + !IsSafeName(item.InputName) || + !IsSafeSymbol(item.Symbol) || + !koreanNames.Add(item.KoreanName) || + !inputNames.Add(item.InputName) || + !symbols.Add(item.Symbol) || + index > 0 && CompareIndustry(items[index - 1], item) > 0) + { + throw InvalidData("industry identity or ordering"); + } + } + + var rows = items.Select((item, index) => + new LegacyOverseasIndustrySearchRow( + $"overseas-industry-{generation:D8}-{index + 1:D4}", + item)); + return Array.AsReadOnly(rows.ToArray()); + } + + private static IReadOnlyList ValidateStockResult( + WorldStockSearchResult? result, + string expectedQuery, + int maximumResults, + long generation) + { + if (result is null || result.Items is null || result.RetrievedAt == default || + !string.Equals(result.Query, expectedQuery, StringComparison.Ordinal)) + { + throw InvalidData("stock search envelope"); + } + + var items = result.Items.ToArray(); + if (items.Length > maximumResults) + { + throw InvalidData("stock result bound"); + } + + var inputNames = new HashSet(StringComparer.Ordinal); + var identities = new HashSet(); + for (var index = 0; index < items.Length; index++) + { + var item = items[index] ?? throw InvalidData("stock identity"); + if (!IsSafeName(item.InputName) || + !IsSafeSymbol(item.Symbol) || + item.NationCode is not ("US" or "TW") || + !inputNames.Add(item.InputName) || + !identities.Add(item) || + index > 0 && CompareStock(items[index - 1], item) > 0) + { + throw InvalidData("stock identity or ordering"); + } + } + + var rows = items.Select((item, index) => + new LegacyOverseasStockSearchRow( + $"overseas-stock-{generation:D8}-{index + 1:D4}", + item)); + return Array.AsReadOnly(rows.ToArray()); + } + + private static int CompareIndustry( + OverseasIndustryIndexItem left, + OverseasIndustryIndexItem right) + { + var comparison = CompareUpper(left.KoreanName, right.KoreanName); + if (comparison != 0) + { + return comparison; + } + + comparison = string.Compare(left.InputName, right.InputName, StringComparison.Ordinal); + return comparison != 0 + ? comparison + : string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal); + } + + private static int CompareStock(WorldStockSearchItem left, WorldStockSearchItem right) + { + var comparison = CompareUpper(left.InputName, right.InputName); + if (comparison != 0) + { + return comparison; + } + + comparison = string.Compare(left.Symbol, right.Symbol, StringComparison.Ordinal); + return comparison != 0 + ? comparison + : string.Compare(left.NationCode, right.NationCode, StringComparison.Ordinal); + } + + private static int CompareUpper(string left, string right) => + string.Compare(left.ToUpperInvariant(), right.ToUpperInvariant(), StringComparison.Ordinal); + + private static string NormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsSafeText(query)) + { + throw new ArgumentException("The overseas search query is invalid.", nameof(query)); + } + + var normalized = query.Trim(); + if (normalized.Length > MaximumQueryLength || !IsSafeText(normalized)) + { + throw new ArgumentException( + $"The overseas search query must contain at most {MaximumQueryLength} safe characters.", + nameof(query)); + } + + return normalized; + } + + private static void ValidateMaximumResults(int maximumResults) + { + if (maximumResults is < 1 or > MaximumResults) + { + throw new ArgumentOutOfRangeException( + nameof(maximumResults), + $"Overseas results must be limited from 1 through {MaximumResults}."); + } + } + + private static bool IsSafeName(string value) => + value.Length is > 0 and <= 200 && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + !value.Contains('|', StringComparison.Ordinal) && + IsSafeText(value); + + private static bool IsSafeSymbol(string value) => + value.Length is > 0 and <= 64 && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + IsSafeText(value) && + value.All(character => + char.IsLetterOrDigit(character) || character is '@' or '.' or '_' or '$' or ':' or ' ' or '-'); + + private static bool IsSafeText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static LegacyOverseasWorkflowException InvalidData(string detail) => + new($"The overseas selector returned an invalid {detail}."); + + private void Touch() => _revision = checked(_revision + 1); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs index 91b3967..8734f8e 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyStockCutCatalog.cs @@ -18,18 +18,24 @@ public sealed record LegacyOperatorPlaylistRow( string PageText, string StockCode, string RawCutLabel, - int FadeDuration) + int FadeDuration, + bool IsSelected = false, + string SceneAlias = "", + LegacySceneSelection? PlayoutSelection = null) { public LegacyPlaylistEntry ToPlayoutEntry() { - var isNxt = StockName.Contains("(NXT)", StringComparison.Ordinal); - var cutCode = LegacyStockCutCatalog.ResolveCutAlias(RawCutLabel, isNxt); + var cutCode = SceneAlias.Length > 0 + ? SceneAlias + : LegacyStockCutCatalog.ResolveCutAlias( + RawCutLabel, + StockName.Contains("(NXT)", StringComparison.Ordinal)); return new LegacyPlaylistEntry( RowId, cutCode, IsEnabled, FadeDuration, - new LegacySceneSelection( + PlayoutSelection ?? new LegacySceneSelection( MarketText, StockName, GraphicType, diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs new file mode 100644 index 0000000..7bd12ee --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/LegacyThemeWorkflow.cs @@ -0,0 +1,822 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using System.Text; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyThemeSort +{ + InputOrder, + GainRateDescending, + GainRateAscending +} + +public enum LegacyThemeValueKind +{ + Current, + Expected +} + +public sealed record LegacyThemeSortDefinition( + string Id, + string Label, + LegacyThemeSort Value); + +public sealed record LegacyThemeActionState( + string ActionId, + bool IsAvailable, + string? UnavailableReason); + +public sealed class LegacyThemeActionDefinition +{ + internal LegacyThemeActionDefinition( + string id, + string label, + string builderKey, + string code, + int pageSize, + LegacyThemeValueKind valueKind) + { + Id = id; + Label = label; + BuilderKey = builderKey; + Code = code; + Aliases = Array.AsReadOnly([code]); + PageSize = pageSize; + ValueKind = valueKind; + } + + public string Id { get; } + + public string Label { get; } + + public string BuilderKey { get; } + + public string Code { get; } + + public IReadOnlyList Aliases { get; } + + public int PageSize { get; } + + public LegacyThemeValueKind ValueKind { get; } +} + +public sealed class LegacyThemeSearchRow +{ + internal LegacyThemeSearchRow( + string resultId, + ThemeSelectionIdentity identity, + DataSourceKind source) + { + ResultId = resultId; + Identity = identity; + Market = identity.Market; + Session = identity.Session; + DisplayTitle = identity.Market == ThemeMarket.Nxt + ? $"{identity.ThemeTitle}(NXT)" + : identity.ThemeTitle; + Source = source; + } + + public string ResultId { get; } + + public ThemeMarket Market { get; } + + public ThemeSession Session { get; } + + public string DisplayTitle { get; } + + public DataSourceKind Source { get; } + + internal ThemeSelectionIdentity Identity { get; } +} + +public sealed class LegacyThemePreviewRow +{ + internal LegacyThemePreviewRow(ThemeItemPreview item) + { + InputIndex = item.InputIndex; + ItemName = item.ItemName; + ItemCode = item.ItemCode; + } + + public int InputIndex { get; } + + public string ItemName { get; } + + internal string ItemCode { get; } +} + +public sealed record LegacySelectedThemeSummary( + ThemeMarket Market, + ThemeSession Session, + string DisplayTitle, + int PreviewItemCount, + bool PreviewIsTruncated); + +public sealed class LegacyThemeWorkflowSnapshot +{ + internal LegacyThemeWorkflowSnapshot( + object authority, + long revision, + string query, + ThemeSession nxtSession, + LegacyThemeSort sort, + IEnumerable searchResults, + bool searchIsTruncated, + string? selectedResultId, + ThemeSelectionIdentity? selectedIdentity, + IEnumerable previewItems, + bool previewIsTruncated) + { + Authority = authority; + Revision = revision; + Query = query; + NxtSession = nxtSession; + Sort = sort; + SearchResults = ReadOnly(searchResults); + SearchIsTruncated = searchIsTruncated; + SelectedResultId = selectedResultId; + SelectedIdentity = selectedIdentity; + PreviewItems = ReadOnly(previewItems); + PreviewIsTruncated = previewIsTruncated; + SelectedTheme = selectedIdentity is null + ? null + : new LegacySelectedThemeSummary( + selectedIdentity.Market, + selectedIdentity.Session, + selectedIdentity.Market == ThemeMarket.Nxt + ? $"{selectedIdentity.ThemeTitle}(NXT)" + : selectedIdentity.ThemeTitle, + PreviewItems.Count, + previewIsTruncated); + ActionStates = LegacyThemeWorkflow.CreateActionStates( + selectedIdentity, + PreviewItems.Count); + } + + public long Revision { get; } + + public string Query { get; } + + public ThemeSession NxtSession { get; } + + public LegacyThemeSort Sort { get; } + + public string SortId => LegacyThemeWorkflow.SortId(Sort); + + public IReadOnlyList SearchResults { get; } + + public bool SearchIsTruncated { get; } + + public string? SelectedResultId { get; } + + public LegacySelectedThemeSummary? SelectedTheme { get; } + + public IReadOnlyList PreviewItems { get; } + + public bool PreviewIsTruncated { get; } + + public IReadOnlyList ActionStates { get; } + + internal object Authority { get; } + + internal ThemeSelectionIdentity? SelectedIdentity { get; } + + private static IReadOnlyList ReadOnly(IEnumerable source) => + Array.AsReadOnly(source.ToArray()); +} + +public sealed record LegacyThemePlaylistSelection( + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string DataCode); + +public sealed record LegacyThemePagePlan( + int PreviewItemCount, + int AccessibleItemCount, + int PageSize, + int PageCount, + int MaximumPageCount, + bool IsTruncated, + string InitialPageText); + +public sealed record LegacyThemePlaylistDraft( + string ActionId, + string BuilderKey, + string Code, + IReadOnlyList Aliases, + string Title, + string Detail, + string Category, + string Market, + LegacyThemePlaylistSelection Selection, + LegacyThemeValueKind ValueKind, + LegacyThemeSort Sort, + int PageSize, + LegacyThemePagePlan PagePlan); + +public sealed class LegacyThemeWorkflowDataException : Exception +{ + public LegacyThemeWorkflowDataException(string message) + : base(message) + { + } +} + +/// +/// C#-authoritative migration of the read-only UC4 theme selector. Search and preview data +/// come only from ; Web callers retain opaque result and +/// action ids and never provide scene codes, resolver groups, theme identities, or PageN data. +/// +public sealed class LegacyThemeWorkflow +{ + public const int MaximumSearchResults = 100; + public const int MaximumPreviewItems = 240; + public const int MaximumPageCount = 20; + + private static readonly IReadOnlyList RegisteredActions = + CreateActions(); + private static readonly IReadOnlyList RegisteredSorts = + Array.AsReadOnly( + [ + new("INPUT_ORDER", "입력순", LegacyThemeSort.InputOrder), + new("GAIN_DESC", "상승률순", LegacyThemeSort.GainRateDescending), + new("GAIN_ASC", "하락률순", LegacyThemeSort.GainRateAscending) + ]); + private static readonly IReadOnlyDictionary ActionsById = + new ReadOnlyDictionary( + RegisteredActions.ToDictionary(action => action.Id, StringComparer.Ordinal)); + + private readonly IThemeSelectionService _selectionService; + private readonly object _authority = new(); + + static LegacyThemeWorkflow() + { + ValidateActions(RegisteredActions); + } + + public LegacyThemeWorkflow(IThemeSelectionService selectionService) + { + _selectionService = selectionService ?? + throw new ArgumentNullException(nameof(selectionService)); + } + + public static IReadOnlyList Actions => RegisteredActions; + + public static IReadOnlyList Sorts => RegisteredSorts; + + public LegacyThemeWorkflowSnapshot CreateInitial( + ThemeSession nxtSession = ThemeSession.PreMarket) + { + RequireNxtSession(nxtSession); + return NewSnapshot( + revision: 0, + query: string.Empty, + nxtSession, + LegacyThemeSort.InputOrder, + [], + searchIsTruncated: false, + selectedResultId: null, + selectedIdentity: null, + previewItems: [], + previewIsTruncated: false); + } + + public async Task SearchAsync( + LegacyThemeWorkflowSnapshot snapshot, + string query, + ThemeSession nxtSession, + CancellationToken cancellationToken = default) + { + RequireSnapshot(snapshot); + var normalizedQuery = NormalizeQuery(query); + RequireNxtSession(nxtSession); + cancellationToken.ThrowIfCancellationRequested(); + + var result = await _selectionService.SearchAsync( + normalizedQuery, + nxtSession, + MaximumSearchResults, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var rows = ValidateSearchResult(result, normalizedQuery, nxtSession); + return NewSnapshot( + checked(snapshot.Revision + 1), + normalizedQuery, + nxtSession, + snapshot.Sort, + rows, + result.IsTruncated, + selectedResultId: null, + selectedIdentity: null, + previewItems: [], + previewIsTruncated: false); + } + + public async Task SelectAsync( + LegacyThemeWorkflowSnapshot snapshot, + string resultId, + CancellationToken cancellationToken = default) + { + RequireSnapshot(snapshot); + ArgumentException.ThrowIfNullOrWhiteSpace(resultId); + var selected = snapshot.SearchResults.FirstOrDefault(row => + string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException($"Unknown theme result id '{resultId}'."); + cancellationToken.ThrowIfCancellationRequested(); + + var preview = await _selectionService.PreviewAsync( + selected.Identity, + MaximumPreviewItems, + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var previewRows = ValidatePreviewResult(preview, selected.Identity); + return NewSnapshot( + checked(snapshot.Revision + 1), + snapshot.Query, + snapshot.NxtSession, + snapshot.Sort, + snapshot.SearchResults, + snapshot.SearchIsTruncated, + selected.ResultId, + selected.Identity, + previewRows, + preview.IsTruncated); + } + + public LegacyThemeWorkflowSnapshot SelectSort( + LegacyThemeWorkflowSnapshot snapshot, + string sortId) + { + RequireSnapshot(snapshot); + var sort = ParseSort(sortId); + return NewSnapshot( + checked(snapshot.Revision + 1), + snapshot.Query, + snapshot.NxtSession, + sort, + snapshot.SearchResults, + snapshot.SearchIsTruncated, + snapshot.SelectedResultId, + snapshot.SelectedIdentity, + snapshot.PreviewItems, + snapshot.PreviewIsTruncated); + } + + /// + /// Materializes from a trusted snapshot and a closed action id only. All K3D-facing values, + /// theme identity fields, sort values, and PageN calculations are rebuilt in this method. + /// + public LegacyThemePlaylistDraft MaterializePlaylistDraft( + LegacyThemeWorkflowSnapshot snapshot, + string actionId) + { + RequireSnapshot(snapshot); + ArgumentException.ThrowIfNullOrWhiteSpace(actionId); + var action = ActionsById.TryGetValue(actionId, out var found) + ? found + : throw new KeyNotFoundException($"Unknown theme action id '{actionId}'."); + var identity = snapshot.SelectedIdentity + ?? throw new InvalidOperationException("A theme must be selected and previewed first."); + if (snapshot.PreviewItems.Count == 0) + { + throw new InvalidOperationException("An empty theme preview cannot be added to the playlist."); + } + + if (action.ValueKind == LegacyThemeValueKind.Expected && + identity.Market == ThemeMarket.Nxt) + { + throw new InvalidOperationException("NXT themes do not provide expected-price data."); + } + + var sortId = SortId(snapshot.Sort); + var valueKind = ValueKindId(action.ValueKind); + var displayTitle = identity.Market == ThemeMarket.Nxt + ? $"{identity.ThemeTitle}(NXT)" + : identity.ThemeTitle; + var selection = identity.Market == ThemeMarket.Krx + ? new LegacyThemePlaylistSelection( + "PAGED_THEME", + identity.ThemeTitle, + sortId, + valueKind, + identity.ThemeCode) + : new LegacyThemePlaylistSelection( + "PAGED_NXT_THEME", + displayTitle, + SessionId(identity.Session), + sortId, + identity.ThemeCode); + + var maximumItems = checked(action.PageSize * MaximumPageCount); + var accessibleItems = Math.Min(snapshot.PreviewItems.Count, maximumItems); + var pageCount = (accessibleItems + action.PageSize - 1) / action.PageSize; + var pagePlan = new LegacyThemePagePlan( + snapshot.PreviewItems.Count, + accessibleItems, + action.PageSize, + pageCount, + MaximumPageCount, + snapshot.PreviewIsTruncated || snapshot.PreviewItems.Count > maximumItems, + $"1/{pageCount.ToString(CultureInfo.InvariantCulture)}"); + + return new LegacyThemePlaylistDraft( + action.Id, + action.BuilderKey, + action.Code, + action.Aliases, + displayTitle, + $"{action.Label} · {SortLabel(snapshot.Sort)}", + "plate", + "theme", + selection, + action.ValueKind, + snapshot.Sort, + action.PageSize, + pagePlan); + } + + public static LegacyThemeActionDefinition? FindAction(string? actionId) => + actionId is not null && ActionsById.TryGetValue(actionId, out var action) + ? action + : null; + + public LegacyThemeActionState GetActionState( + LegacyThemeWorkflowSnapshot snapshot, + string actionId) + { + RequireSnapshot(snapshot); + ArgumentException.ThrowIfNullOrWhiteSpace(actionId); + return snapshot.ActionStates.FirstOrDefault(state => + string.Equals(state.ActionId, actionId, StringComparison.Ordinal)) + ?? throw new KeyNotFoundException($"Unknown theme action id '{actionId}'."); + } + + public static string SortId(LegacyThemeSort sort) => sort switch + { + LegacyThemeSort.InputOrder => "INPUT_ORDER", + LegacyThemeSort.GainRateDescending => "GAIN_DESC", + LegacyThemeSort.GainRateAscending => "GAIN_ASC", + _ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.") + }; + + public static string SortLabel(LegacyThemeSort sort) => sort switch + { + LegacyThemeSort.InputOrder => "입력순", + LegacyThemeSort.GainRateDescending => "상승률순", + LegacyThemeSort.GainRateAscending => "하락률순", + _ => throw new ArgumentOutOfRangeException(nameof(sort), sort, "Unknown theme sort.") + }; + + private LegacyThemeWorkflowSnapshot NewSnapshot( + long revision, + string query, + ThemeSession nxtSession, + LegacyThemeSort sort, + IEnumerable searchResults, + bool searchIsTruncated, + string? selectedResultId, + ThemeSelectionIdentity? selectedIdentity, + IEnumerable previewItems, + bool previewIsTruncated) => + new( + _authority, + revision, + query, + nxtSession, + sort, + searchResults, + searchIsTruncated, + selectedResultId, + selectedIdentity, + previewItems, + previewIsTruncated); + + private void RequireSnapshot(LegacyThemeWorkflowSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (!ReferenceEquals(snapshot.Authority, _authority)) + { + throw new ArgumentException( + "The theme snapshot belongs to another workflow instance.", + nameof(snapshot)); + } + } + + private static IReadOnlyList ValidateSearchResult( + ThemeSearchResult result, + string query, + ThemeSession nxtSession) + { + if (result is null || result.Items is null || + !string.Equals(result.Query, query, StringComparison.Ordinal) || + result.NxtSession != nxtSession) + { + throw InvalidData("search envelope"); + } + + var serviceItems = result.Items.ToArray(); + if (serviceItems.Length > MaximumSearchResults || + result.IsTruncated && serviceItems.Length != MaximumSearchResults) + { + throw InvalidData("search envelope"); + } + + var titles = new Dictionary> + { + [ThemeMarket.Krx] = new(StringComparer.Ordinal), + [ThemeMarket.Nxt] = new(StringComparer.Ordinal) + }; + var codes = new Dictionary> + { + [ThemeMarket.Krx] = new(StringComparer.Ordinal), + [ThemeMarket.Nxt] = new(StringComparer.Ordinal) + }; + foreach (var item in serviceItems) + { + if (item is null) + { + throw InvalidData("search item"); + } + + ValidateIdentity(item.Identity, nxtSession); + var expectedSource = item.Identity.Market == ThemeMarket.Krx + ? DataSourceKind.Oracle + : DataSourceKind.MariaDb; + if (item.Source != expectedSource || + !titles[item.Identity.Market].Add(item.Identity.ThemeTitle) || + !codes[item.Identity.Market].Add(item.Identity.ThemeCode)) + { + throw InvalidData("search identity or provider"); + } + } + + var ordered = serviceItems + .OrderBy(item => item.Identity.Market) + .ThenBy(item => item.Identity.ThemeTitle, StringComparer.Ordinal) + .ThenBy(item => item.Identity.ThemeCode, StringComparer.Ordinal) + .ToArray(); + var rows = new LegacyThemeSearchRow[ordered.Length]; + for (var index = 0; index < ordered.Length; index++) + { + rows[index] = new LegacyThemeSearchRow( + $"theme-result-{index + 1:000}", + ordered[index].Identity, + ordered[index].Source); + } + + return Array.AsReadOnly(rows); + } + + private static IReadOnlyList ValidatePreviewResult( + ThemePreviewResult result, + ThemeSelectionIdentity identity) + { + if (result is null || result.Items is null || result.Identity != identity) + { + throw InvalidData("preview envelope"); + } + + var serviceItems = result.Items.ToArray(); + if (serviceItems.Length > MaximumPreviewItems || + result.IsTruncated && serviceItems.Length != MaximumPreviewItems) + { + throw InvalidData("preview envelope"); + } + + var itemCodes = new HashSet(StringComparer.Ordinal); + var indexes = new HashSet(); + var previousIndex = -1; + var rows = new LegacyThemePreviewRow[serviceItems.Length]; + for (var index = 0; index < serviceItems.Length; index++) + { + var item = serviceItems[index] + ?? throw InvalidData("preview item"); + if (item.InputIndex is < 0 or > 9_999 || item.InputIndex <= previousIndex || + !indexes.Add(item.InputIndex) || !itemCodes.Add(item.ItemCode) || + !IsItemCodeForMarket(item.ItemCode, identity.Market) || + !IsCanonicalText(item.ItemName, 200)) + { + throw InvalidData("preview item identity"); + } + + previousIndex = item.InputIndex; + rows[index] = new LegacyThemePreviewRow(item); + } + + return Array.AsReadOnly(rows); + } + + private static void ValidateIdentity( + ThemeSelectionIdentity identity, + ThemeSession requestedNxtSession) + { + if (identity is null || !Enum.IsDefined(identity.Market) || + !Enum.IsDefined(identity.Session) || + identity.Market == ThemeMarket.Krx && + identity.Session != ThemeSession.NotApplicable || + identity.Market == ThemeMarket.Nxt && identity.Session != requestedNxtSession || + !IsThemeCode(identity.ThemeCode) || + !IsCanonicalText(identity.ThemeTitle, 128) || + identity.ThemeTitle.Contains("(NXT)", StringComparison.Ordinal)) + { + throw InvalidData("theme identity"); + } + } + + private static string NormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + if (!IsSafeText(query)) + { + throw new ArgumentException("The theme search query is invalid.", nameof(query)); + } + + var normalized = query.Trim(); + if (normalized.Length > LegacyThemeSelectionService.MaximumQueryLength || + !IsSafeText(normalized)) + { + throw new ArgumentException( + $"A theme search query must contain at most {LegacyThemeSelectionService.MaximumQueryLength} visible characters.", + nameof(query)); + } + + return normalized; + } + + private static LegacyThemeSort ParseSort(string sortId) + { + ArgumentNullException.ThrowIfNull(sortId); + return sortId switch + { + "INPUT_ORDER" => LegacyThemeSort.InputOrder, + "GAIN_DESC" => LegacyThemeSort.GainRateDescending, + "GAIN_ASC" => LegacyThemeSort.GainRateAscending, + _ => throw new ArgumentOutOfRangeException( + nameof(sortId), + sortId, + "Unknown theme sort id.") + }; + } + + private static string SessionId(ThemeSession session) => session switch + { + ThemeSession.PreMarket => "PRE_MARKET", + ThemeSession.AfterMarket => "AFTER_MARKET", + _ => throw new ArgumentOutOfRangeException( + nameof(session), + session, + "NXT requires an explicit session.") + }; + + private static string ValueKindId(LegacyThemeValueKind valueKind) => valueKind switch + { + LegacyThemeValueKind.Current => "CURRENT", + LegacyThemeValueKind.Expected => "EXPECTED", + _ => throw new ArgumentOutOfRangeException( + nameof(valueKind), + valueKind, + "Unknown theme value kind.") + }; + + private static void RequireNxtSession(ThemeSession nxtSession) + { + if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) + { + throw new ArgumentOutOfRangeException( + nameof(nxtSession), + nxtSession, + "An explicit NXT session is required."); + } + } + + private static bool IsThemeCode(string value) => + value is not null && value.Length is >= 3 and <= 8 && value.All(char.IsAsciiDigit); + + private static bool IsItemCodeForMarket(string value, ThemeMarket market) + { + if (value is null || value.Length is < 2 or > 13 || !value.All(char.IsAsciiLetterOrDigit)) + { + return false; + } + + return market switch + { + ThemeMarket.Krx => value[0] is 'P' or 'D', + ThemeMarket.Nxt => value[0] is 'X' or 'T', + _ => false + }; + } + + private static bool IsCanonicalText(string value, int maximumLength) => + value is not null && value.Length is >= 1 && + value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + IsSafeText(value); + + private static bool IsSafeText(string value) + { + foreach (var rune in value.EnumerateRunes()) + { + var category = Rune.GetUnicodeCategory(rune); + if (Rune.IsControl(rune) || rune.Value == '\uFFFD' || + category is UnicodeCategory.Format or UnicodeCategory.Surrogate or + UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private static IReadOnlyList CreateActions() => + Array.AsReadOnly( + [ + new LegacyThemeActionDefinition( + "five-row-current", + "5단 표그래프 · 현재가", + "s5074", + "5074", + 5, + LegacyThemeValueKind.Current), + new LegacyThemeActionDefinition( + "five-row-expected", + "5단 표그래프 · 예상체결가", + "s5074", + "5074", + 5, + LegacyThemeValueKind.Expected), + new LegacyThemeActionDefinition( + "six-row-current", + "6종목 현재가", + "s5077", + "5077", + 6, + LegacyThemeValueKind.Current), + new LegacyThemeActionDefinition( + "twelve-row-current", + "12종목 현재가", + "s5088", + "5088", + 12, + LegacyThemeValueKind.Current) + ]); + + internal static IReadOnlyList CreateActionStates( + ThemeSelectionIdentity? selectedIdentity, + int previewItemCount) + { + var states = new LegacyThemeActionState[RegisteredActions.Count]; + for (var index = 0; index < RegisteredActions.Count; index++) + { + var action = RegisteredActions[index]; + var reason = selectedIdentity is null + ? "theme-not-selected" + : previewItemCount == 0 + ? "theme-preview-is-empty" + : action.ValueKind == LegacyThemeValueKind.Expected && + selectedIdentity.Market == ThemeMarket.Nxt + ? "expected-theme-is-krx-only" + : null; + states[index] = new LegacyThemeActionState( + action.Id, + reason is null, + reason); + } + + return Array.AsReadOnly(states); + } + + private static void ValidateActions( + IReadOnlyCollection actions) + { + if (actions.Count != 4 || + actions.Select(action => action.Id).Distinct(StringComparer.Ordinal).Count() != 4) + { + throw new InvalidOperationException("The UC4 action catalog must contain four unique actions."); + } + + foreach (var action in actions) + { + var scene = LegacySceneCatalog.Definitions.Single(definition => + string.Equals(definition.BuilderKey, action.BuilderKey, StringComparison.Ordinal)); + if (!scene.CutAliases.Contains(action.Code, StringComparer.Ordinal) || + action.Aliases.Count != 1 || + !string.Equals(action.Aliases[0], action.Code, StringComparison.Ordinal) || + (int)scene.PageSize != action.PageSize) + { + throw new InvalidOperationException( + $"Theme action '{action.Id}' does not match the Core scene catalog."); + } + } + } + + private static LegacyThemeWorkflowDataException InvalidData(string detail) => + new($"The injected theme selection service returned invalid {detail} data."); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/MBN_STOCK_WEBVIEW.LegacyApplication.csproj b/src/MBN_STOCK_WEBVIEW.LegacyApplication/MBN_STOCK_WEBVIEW.LegacyApplication.csproj index ec7bd4c..5958eae 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyApplication/MBN_STOCK_WEBVIEW.LegacyApplication.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/MBN_STOCK_WEBVIEW.LegacyApplication.csproj @@ -14,4 +14,9 @@ + + + + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs new file mode 100644 index 0000000..0f2f8c0 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualFinancial/LegacyManualFinancialWorkflow.cs @@ -0,0 +1,1646 @@ +using System.Collections.Concurrent; +using System.Collections.ObjectModel; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyManualFinancialWorkflowStatus +{ + Idle, + ListLoaded, + DetailLoaded, + StockCandidatesLoaded, + StockVerified, + WriteCommitted, + WriteRejected, + ReadBackRequired, + OutcomeUnknown +} + +public enum LegacyManualFinancialFindDirection +{ + Up, + Down +} + +public enum LegacyManualFinancialNameSortDirection +{ + Ascending, + Descending +} + +public sealed class LegacyManualFinancialScreenDefinition +{ + internal LegacyManualFinancialScreenDefinition( + ManualFinancialScreenKind screen, + string actionId, + string label, + string tableName, + string builderKey, + string code, + string canonicalGraphicType, + string deleteAllConfirmationToken) + { + Screen = screen; + ActionId = actionId; + Label = label; + TableName = tableName; + BuilderKey = builderKey; + Code = code; + Aliases = Array.AsReadOnly([code]); + CanonicalGraphicType = canonicalGraphicType; + DeleteAllConfirmationToken = deleteAllConfirmationToken; + } + + public ManualFinancialScreenKind Screen { get; } + + public string ActionId { get; } + + public string Label { get; } + + public string TableName { get; } + + public string BuilderKey { get; } + + public string Code { get; } + + public IReadOnlyList Aliases { get; } + + public string CanonicalGraphicType { get; } + + public string DeleteAllConfirmationToken { get; } +} + +public sealed class LegacyManualFinancialListRow +{ + internal LegacyManualFinancialListRow(string resultId, ManualFinancialSnapshot snapshot) + { + ResultId = resultId; + Snapshot = snapshot; + } + + public string ResultId { get; } + + public string StockName => Snapshot.Record.Identity.StockName; + + public string RowVersion => Snapshot.RowVersion; + + internal ManualFinancialSnapshot Snapshot { get; } +} + +public sealed class LegacyManualFinancialStockRow +{ + internal LegacyManualFinancialStockRow(string resultId, StockSearchItem item) + { + ResultId = resultId; + Item = item; + } + + public string ResultId { get; } + + public StockMarket Market => Item.Market; + + public string Name => Item.Name; + + public string Code => Item.Code; + + internal StockSearchItem Item { get; } +} + +public sealed record LegacyManualFinancialVerifiedStockSummary( + StockMarket Market, + string Name, + string Code); + +public sealed record LegacyManualFinancialPlaylistDraft( + string ActionId, + string BuilderKey, + string Code, + IReadOnlyList Aliases, + string Title, + string Detail, + string Category, + string Market, + string StockCode, + bool IsNxt, + ManualFinancialCutSelection CutSelection, + ManualFinancialSnapshot OperatorSnapshot); + +public sealed record LegacyManualFinancialWorkflowSnapshot +{ + internal LegacyManualFinancialWorkflowSnapshot( + object authority, + long revision, + LegacyManualFinancialScreenDefinition definition) + { + Authority = authority; + Revision = revision; + Definition = definition; + Query = string.Empty; + FindQuery = string.Empty; + NameSortDirection = LegacyManualFinancialNameSortDirection.Ascending; + Rows = Array.Empty(); + StockQuery = string.Empty; + StockCandidates = Array.Empty(); + } + + public long Revision { get; internal init; } + + public LegacyManualFinancialScreenDefinition Definition { get; internal init; } + + public ManualFinancialScreenKind Screen => Definition.Screen; + + public string Query { get; internal init; } + + public string FindQuery { get; internal init; } + + public LegacyManualFinancialNameSortDirection NameSortDirection { get; internal init; } + + public IReadOnlyList Rows { get; internal init; } + + public bool ListIsTruncated { get; internal init; } + + public string? SelectedRowId { get; internal init; } + + public ManualFinancialRecord? SelectedRecord => Detail?.Record; + + public string? SelectedRowVersion => Detail?.RowVersion; + + public bool IsDetailFresh { get; internal init; } + + public string StockQuery { get; internal init; } + + public IReadOnlyList StockCandidates { get; internal init; } + + public bool StockSearchIsTruncated { get; internal init; } + + public string? SelectedStockResultId { get; internal init; } + + public LegacyManualFinancialVerifiedStockSummary? VerifiedStock => + Verified is null + ? null + : new LegacyManualFinancialVerifiedStockSummary( + Verified.Market, + Verified.Name, + Verified.Code); + + public LegacyManualFinancialWorkflowStatus Status { get; internal init; } + + public bool WritesQuarantined { get; internal init; } + + public string? LastMessage { get; internal init; } + + public string? LastRequestId { get; internal init; } + + public ManualFinancialMutationReceipt? LastReceipt { get; internal init; } + + public bool CanSave => + !WritesQuarantined && + Verified is not null && + (Detail is null || + IsDetailFresh && + string.Equals(VerifiedForRowVersion, Detail.RowVersion, StringComparison.Ordinal) && + string.Equals( + Verified.Name, + Detail.Record.Identity.StockName, + StringComparison.Ordinal)); + + public bool CanDelete => + !WritesQuarantined && IsDetailFresh && Detail is not null; + + public bool CanMaterializePlaylist => + CanDelete && + Verified is not null && + string.Equals(VerifiedForRowVersion, Detail!.RowVersion, StringComparison.Ordinal) && + string.Equals( + Verified.Name, + Detail.Record.Identity.StockName, + StringComparison.Ordinal); + + internal object Authority { get; } + + internal long WriteEpoch { get; init; } + + internal ManualFinancialSnapshot? Detail { get; init; } + + internal VerifiedManualFinancialStock? Verified { get; init; } + + internal string? VerifiedForRowVersion { get; init; } +} + +public abstract class LegacyManualFinancialWriteRequest +{ + internal LegacyManualFinancialWriteRequest( + object authority, + long sourceRevision, + long sourceWriteEpoch, + ManualFinancialMutationKind kind, + ManualFinancialScreenKind screen, + string stockName) + { + Authority = authority; + SourceRevision = sourceRevision; + SourceWriteEpoch = sourceWriteEpoch; + Kind = kind; + Screen = screen; + StockName = stockName; + RequestId = Guid.NewGuid().ToString("N"); + } + + public string RequestId { get; } + + public ManualFinancialMutationKind Kind { get; } + + public ManualFinancialScreenKind Screen { get; } + + public string StockName { get; } + + internal object Authority { get; } + + internal long SourceRevision { get; } + + internal long SourceWriteEpoch { get; } + + internal virtual VerifiedManualFinancialStock? VerifiedStock => null; +} + +public sealed class LegacyManualFinancialCreateRequest : LegacyManualFinancialWriteRequest +{ + internal LegacyManualFinancialCreateRequest( + object authority, + long sourceRevision, + long sourceWriteEpoch, + ManualFinancialRecord record, + VerifiedManualFinancialStock verifiedStock) + : base( + authority, + sourceRevision, + sourceWriteEpoch, + ManualFinancialMutationKind.Create, + record.Identity.Screen, + record.Identity.StockName) + { + Record = record; + Stock = verifiedStock; + } + + public ManualFinancialRecord Record { get; } + + internal override VerifiedManualFinancialStock VerifiedStock => Stock; + + private VerifiedManualFinancialStock Stock { get; } +} + +public sealed class LegacyManualFinancialUpdateRequest : LegacyManualFinancialWriteRequest +{ + internal LegacyManualFinancialUpdateRequest( + object authority, + long sourceRevision, + long sourceWriteEpoch, + ManualFinancialSnapshot expected, + ManualFinancialRecord replacement, + VerifiedManualFinancialStock verifiedStock) + : base( + authority, + sourceRevision, + sourceWriteEpoch, + ManualFinancialMutationKind.Update, + expected.Record.Identity.Screen, + expected.Record.Identity.StockName) + { + Expected = expected; + Replacement = replacement; + Stock = verifiedStock; + } + + public string ExpectedRowVersion => Expected.RowVersion; + + public ManualFinancialRecord Replacement { get; } + + internal ManualFinancialSnapshot Expected { get; } + + internal override VerifiedManualFinancialStock VerifiedStock => Stock; + + private VerifiedManualFinancialStock Stock { get; } +} + +public sealed class LegacyManualFinancialDeleteRequest : LegacyManualFinancialWriteRequest +{ + internal LegacyManualFinancialDeleteRequest( + object authority, + long sourceRevision, + long sourceWriteEpoch, + ManualFinancialSnapshot expected) + : base( + authority, + sourceRevision, + sourceWriteEpoch, + ManualFinancialMutationKind.DeleteOne, + expected.Record.Identity.Screen, + expected.Record.Identity.StockName) + { + Expected = expected; + } + + public string ExpectedRowVersion => Expected.RowVersion; + + internal ManualFinancialSnapshot Expected { get; } +} + +public sealed class LegacyManualFinancialDeleteAllRequest : LegacyManualFinancialWriteRequest +{ + internal LegacyManualFinancialDeleteAllRequest( + object authority, + long sourceRevision, + long sourceWriteEpoch, + LegacyManualFinancialScreenDefinition definition) + : base( + authority, + sourceRevision, + sourceWriteEpoch, + ManualFinancialMutationKind.DeleteAll, + definition.Screen, + string.Empty) + { + ConfirmationToken = definition.DeleteAllConfirmationToken; + } + + public string ConfirmationToken { get; } +} + +public sealed class LegacyManualFinancialWorkflowDataException : Exception +{ + public LegacyManualFinancialWorkflowDataException(string message) + : base(message) + { + } +} + +internal sealed class LegacyManualFinancialWriteSafetyState +{ + private readonly object _gate = new(); + private long _writeEpoch; + private bool _writesQuarantined; + private string? _quarantineReason; + + internal SemaphoreSlim WriteGate { get; } = new(1, 1); + + internal long CurrentEpoch + { + get + { + lock (_gate) + { + return _writeEpoch; + } + } + } + + internal bool IsCurrent(long writeEpoch) + { + lock (_gate) + { + return writeEpoch == _writeEpoch; + } + } + + internal bool IsQuarantined(out string? reason) + { + lock (_gate) + { + reason = _quarantineReason; + return _writesQuarantined; + } + } + + internal bool TryBeginWrite( + long expectedEpoch, + out long writeEpoch, + out string reason) + { + lock (_gate) + { + writeEpoch = _writeEpoch; + if (_writesQuarantined) + { + reason = $"Manual-financial writes are quarantined: {_quarantineReason}"; + return false; + } + + if (expectedEpoch != _writeEpoch) + { + reason = "The manual-financial write request belongs to a stale workflow state."; + return false; + } + + writeEpoch = checked(++_writeEpoch); + reason = string.Empty; + return true; + } + } + + internal string Quarantine(string reason) + { + lock (_gate) + { + _writesQuarantined = true; + _quarantineReason ??= reason; + return _quarantineReason; + } + } +} + +/// +/// C#-authoritative migration of Form/GraphE.cs. Web callers receive opaque row, +/// stock and action ids; fixed Core contracts own INPUT_* identities, optimistic +/// row versions and scene codes. Every write request is one-shot. An uncertain +/// outcome latches a process-lifetime write quarantine and is never retried. +/// +public sealed class LegacyManualFinancialWorkflow +{ + public const int MaximumListResults = 200; + public const int MaximumStockResults = 100; + + private const string ValidationRowVersion = + "0000000000000000000000000000000000000000000000000000000000000000"; + private const string ValidationStockCode = "VALIDATION1"; + + private static readonly IReadOnlyList + RegisteredScreens = CreateDefinitions(); + private static readonly IReadOnlyDictionary ScreensByKind = + new ReadOnlyDictionary( + RegisteredScreens.ToDictionary(definition => definition.Screen)); + private static readonly IReadOnlyDictionary + ScreensByActionId = + new ReadOnlyDictionary( + RegisteredScreens.ToDictionary( + definition => definition.ActionId, + StringComparer.Ordinal)); + private static readonly LegacyManualFinancialWriteSafetyState ProcessWriteSafety = new(); + + private readonly IManualFinancialScreenService _screenService; + private readonly IStockSearchService _stockSearchService; + private readonly LegacyManualFinancialWriteSafetyState _writeSafety; + private readonly object _authority = new(); + + static LegacyManualFinancialWorkflow() + { + ValidateDefinitions(RegisteredScreens); + } + + public LegacyManualFinancialWorkflow( + IManualFinancialScreenService screenService, + IStockSearchService stockSearchService) + : this(screenService, stockSearchService, ProcessWriteSafety) + { + } + + internal LegacyManualFinancialWorkflow( + IManualFinancialScreenService screenService, + IStockSearchService stockSearchService, + LegacyManualFinancialWriteSafetyState writeSafety) + { + _screenService = screenService ?? throw new ArgumentNullException(nameof(screenService)); + _stockSearchService = stockSearchService ?? + throw new ArgumentNullException(nameof(stockSearchService)); + _writeSafety = writeSafety ?? throw new ArgumentNullException(nameof(writeSafety)); + } + + public static IReadOnlyList Screens => + RegisteredScreens; + + public static LegacyManualFinancialScreenDefinition GetScreen( + ManualFinancialScreenKind screen) => + ScreensByKind.TryGetValue(screen, out var definition) + ? definition + : throw new ArgumentOutOfRangeException(nameof(screen)); + + public LegacyManualFinancialWorkflowSnapshot CreateInitial( + ManualFinancialScreenKind screen) => + new(_authority, 0, GetScreen(screen)) + { + WriteEpoch = _writeSafety.CurrentEpoch, + WritesQuarantined = IsQuarantined(out var reason), + LastMessage = reason + }; + + public async Task SearchAsync( + LegacyManualFinancialWorkflowSnapshot snapshot, + string query = "", + CancellationToken cancellationToken = default) + { + snapshot = RequireOwned(snapshot); + var normalizedQuery = NormalizeQuery(query, allowEmpty: true); + var result = await _screenService.SearchAsync( + snapshot.Screen, + normalizedQuery, + MaximumListResults, + cancellationToken).ConfigureAwait(false); + if (result is null || result.Screen != snapshot.Screen || + !string.Equals(result.Query, normalizedQuery, StringComparison.Ordinal) || + result.Items is null || result.Items.Count > MaximumListResults) + { + throw InvalidData("The manual-financial list response is not correlated to its request."); + } + + var rows = new List(result.Items.Count); + var identities = new HashSet(StringComparer.Ordinal); + foreach (var item in result.Items) + { + var validated = ValidateSnapshot(item, snapshot.Screen, "list response"); + if (!identities.Add(validated.Record.Identity.StockName)) + { + throw InvalidData("The manual-financial list contains an ambiguous STOCK_NAME identity."); + } + + rows.Add(new LegacyManualFinancialListRow(NewOpaqueId("mf-row"), validated)); + } + + rows.Sort(static (left, right) => + StringComparer.Ordinal.Compare(left.StockName, right.StockName)); + + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + Query = normalizedQuery, + FindQuery = string.Empty, + NameSortDirection = LegacyManualFinancialNameSortDirection.Ascending, + Rows = Array.AsReadOnly(rows.ToArray()), + ListIsTruncated = result.IsTruncated, + SelectedRowId = null, + Detail = null, + IsDetailFresh = false, + StockQuery = string.Empty, + StockCandidates = Array.Empty(), + StockSearchIsTruncated = false, + SelectedStockResultId = null, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.ListLoaded, + WritesQuarantined = IsQuarantined(out var reason), + LastMessage = reason, + LastRequestId = null, + LastReceipt = null + }; + } + + public LegacyManualFinancialWorkflowSnapshot ToggleNameSort( + LegacyManualFinancialWorkflowSnapshot snapshot, + long expectedRevision) + { + snapshot = RequireCurrentRevision(snapshot, expectedRevision); + var direction = snapshot.NameSortDirection == + LegacyManualFinancialNameSortDirection.Ascending + ? LegacyManualFinancialNameSortDirection.Descending + : LegacyManualFinancialNameSortDirection.Ascending; + var rows = SortRows(snapshot.Rows, direction); + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + Rows = rows, + NameSortDirection = direction, + LastMessage = null + }; + } + + public LegacyManualFinancialWorkflowSnapshot FindByName( + LegacyManualFinancialWorkflowSnapshot snapshot, + string query, + LegacyManualFinancialFindDirection direction, + long expectedRevision) + { + snapshot = RequireCurrentRevision(snapshot, expectedRevision); + if (!Enum.IsDefined(direction)) + { + throw new ArgumentOutOfRangeException(nameof(direction)); + } + + var normalizedQuery = NormalizeQuery(query, allowEmpty: false); + var selectedIndex = snapshot.SelectedRowId is null + ? -1 + : snapshot.Rows.ToList().FindIndex(row => string.Equals( + row.ResultId, + snapshot.SelectedRowId, + StringComparison.Ordinal)); + var matchIndex = -1; + if (direction == LegacyManualFinancialFindDirection.Down) + { + for (var index = selectedIndex + 1; index < snapshot.Rows.Count; index++) + { + if (snapshot.Rows[index].StockName.Contains( + normalizedQuery, + StringComparison.OrdinalIgnoreCase)) + { + matchIndex = index; + break; + } + } + } + else if (selectedIndex >= 0) + { + for (var index = selectedIndex - 1; index >= 0; index--) + { + if (snapshot.Rows[index].StockName.Contains( + normalizedQuery, + StringComparison.OrdinalIgnoreCase)) + { + matchIndex = index; + break; + } + } + } + + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + FindQuery = normalizedQuery, + SelectedRowId = matchIndex >= 0 + ? snapshot.Rows[matchIndex].ResultId + : snapshot.SelectedRowId, + Detail = matchIndex >= 0 ? null : snapshot.Detail, + IsDetailFresh = matchIndex < 0 && snapshot.IsDetailFresh, + StockQuery = matchIndex >= 0 ? string.Empty : snapshot.StockQuery, + StockCandidates = matchIndex >= 0 + ? Array.Empty() + : snapshot.StockCandidates, + StockSearchIsTruncated = matchIndex < 0 && snapshot.StockSearchIsTruncated, + SelectedStockResultId = matchIndex >= 0 + ? null + : snapshot.SelectedStockResultId, + Verified = matchIndex >= 0 ? null : snapshot.Verified, + VerifiedForRowVersion = matchIndex >= 0 + ? null + : snapshot.VerifiedForRowVersion, + Status = matchIndex >= 0 + ? LegacyManualFinancialWorkflowStatus.ListLoaded + : snapshot.Status, + LastMessage = matchIndex >= 0 + ? null + : "No further matching stock was found." + }; + } + + public async Task LoadAsync( + LegacyManualFinancialWorkflowSnapshot snapshot, + string resultId, + CancellationToken cancellationToken = default) + { + snapshot = RequireOwned(snapshot); + var row = FindRow(snapshot.Rows, resultId); + var identity = row.Snapshot.Record.Identity; + var detail = await _screenService.GetAsync(identity, cancellationToken).ConfigureAwait(false); + var validated = ValidateSnapshot(detail, snapshot.Screen, "detail response"); + if (!EqualsIdentity(identity, validated.Record.Identity)) + { + throw InvalidData("The manual-financial detail response is not correlated to its request."); + } + + var rows = ReplaceRow(snapshot.Rows, resultId, validated); + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + Rows = rows, + SelectedRowId = resultId, + Detail = validated, + IsDetailFresh = true, + StockQuery = string.Empty, + StockCandidates = Array.Empty(), + StockSearchIsTruncated = false, + SelectedStockResultId = null, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.DetailLoaded, + WritesQuarantined = IsQuarantined(out var reason), + LastMessage = reason, + LastRequestId = null, + LastReceipt = null + }; + } + + public LegacyManualFinancialWorkflowSnapshot BeginCreate( + LegacyManualFinancialWorkflowSnapshot snapshot) + { + snapshot = RequireOwned(snapshot); + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + SelectedRowId = null, + Detail = null, + IsDetailFresh = false, + StockQuery = string.Empty, + StockCandidates = Array.Empty(), + StockSearchIsTruncated = false, + SelectedStockResultId = null, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.ListLoaded, + WritesQuarantined = IsQuarantined(out var reason), + LastMessage = reason, + LastRequestId = null, + LastReceipt = null + }; + } + + public async Task SearchStocksAsync( + LegacyManualFinancialWorkflowSnapshot snapshot, + string stockName, + CancellationToken cancellationToken = default) + { + snapshot = RequireOwned(snapshot); + var normalizedName = NormalizeQuery(stockName, allowEmpty: false); + var result = await _stockSearchService.SearchAsync( + normalizedName, + MaximumStockResults, + cancellationToken).ConfigureAwait(false); + if (result is null || + !string.Equals(result.Query, normalizedName, StringComparison.Ordinal) || + result.Items is null || result.Items.Count > MaximumStockResults) + { + throw InvalidData("The stock-search response is not correlated to its request."); + } + + var candidates = new List(result.Items.Count); + foreach (var item in result.Items) + { + ValidateStockItem(item); + candidates.Add(new LegacyManualFinancialStockRow( + NewOpaqueId("mf-stock"), + item with { })); + } + + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + StockQuery = normalizedName, + StockCandidates = Array.AsReadOnly(candidates.ToArray()), + StockSearchIsTruncated = result.IsTruncated, + SelectedStockResultId = null, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.StockCandidatesLoaded, + WritesQuarantined = IsQuarantined(out var reason), + LastMessage = reason, + LastRequestId = null, + LastReceipt = null + }; + } + + public LegacyManualFinancialWorkflowSnapshot SelectStock( + LegacyManualFinancialWorkflowSnapshot snapshot, + string stockResultId) + { + snapshot = RequireOwned(snapshot); + var row = FindStock(snapshot.StockCandidates, stockResultId); + var identity = snapshot.Detail?.Record.Identity ?? + new ManualFinancialIdentity(snapshot.Screen, row.Item.Name); + var candidates = snapshot.StockCandidates.Select(candidate => candidate.Item).ToArray(); + var verified = ManualFinancialCutContracts.VerifyStock( + identity, + candidates, + row.Item.Market, + row.Item.Code); + var verifiedForRowVersion = snapshot.Detail is null + ? null + : snapshot.Detail.RowVersion; + + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + SelectedStockResultId = stockResultId, + Verified = verified, + VerifiedForRowVersion = verifiedForRowVersion, + Status = LegacyManualFinancialWorkflowStatus.StockVerified, + WritesQuarantined = IsQuarantined(out var reason), + LastMessage = reason, + LastRequestId = null, + LastReceipt = null + }; + } + + public LegacyManualFinancialCreateRequest CreateCreateRequest( + LegacyManualFinancialWorkflowSnapshot snapshot, + ManualFinancialRecord record) + { + snapshot = RequireWritable(snapshot); + if (snapshot.Detail is not null) + { + throw new InvalidOperationException("BeginCreate must be used before creating a new record."); + } + + var verified = snapshot.Verified ?? throw new InvalidOperationException( + "An exact stock-master row must be verified before creating a record."); + var validated = ValidateRecord(record, snapshot.Screen, verified, nameof(record)); + return new LegacyManualFinancialCreateRequest( + _authority, + snapshot.Revision, + snapshot.WriteEpoch, + validated, + verified); + } + + public LegacyManualFinancialUpdateRequest CreateUpdateRequest( + LegacyManualFinancialWorkflowSnapshot snapshot, + ManualFinancialRecord replacement) + { + snapshot = RequireWritable(snapshot); + var expected = RequireFreshDetail(snapshot); + var verified = RequireVerifiedForDetail(snapshot, expected); + var validated = ValidateRecord(replacement, snapshot.Screen, verified, nameof(replacement)); + if (!EqualsIdentity(expected.Record.Identity, validated.Identity)) + { + throw new ArgumentException( + "A manual-financial update cannot rename or change screens.", + nameof(replacement)); + } + + return new LegacyManualFinancialUpdateRequest( + _authority, + snapshot.Revision, + snapshot.WriteEpoch, + expected, + validated, + verified); + } + + public LegacyManualFinancialDeleteRequest CreateDeleteRequest( + LegacyManualFinancialWorkflowSnapshot snapshot) + { + snapshot = RequireWritable(snapshot); + var expected = RequireFreshDetail(snapshot); + return new LegacyManualFinancialDeleteRequest( + _authority, + snapshot.Revision, + snapshot.WriteEpoch, + expected); + } + + public LegacyManualFinancialDeleteAllRequest CreateDeleteAllRequest( + LegacyManualFinancialWorkflowSnapshot snapshot, + string confirmationToken) + { + snapshot = RequireWritable(snapshot); + if (!string.Equals( + confirmationToken, + snapshot.Definition.DeleteAllConfirmationToken, + StringComparison.Ordinal)) + { + throw new ArgumentException( + "The delete-all confirmation token is invalid.", + nameof(confirmationToken)); + } + + return new LegacyManualFinancialDeleteAllRequest( + _authority, + snapshot.Revision, + snapshot.WriteEpoch, + snapshot.Definition); + } + + public async Task ExecuteAsync( + LegacyManualFinancialWorkflowSnapshot snapshot, + LegacyManualFinancialWriteRequest request, + CancellationToken cancellationToken = default) + { + snapshot = RequireWritable(snapshot); + ArgumentNullException.ThrowIfNull(request); + if (!ReferenceEquals(request.Authority, _authority) || + request.SourceRevision != snapshot.Revision || + request.SourceWriteEpoch != snapshot.WriteEpoch || + request.Screen != snapshot.Screen) + { + throw new ArgumentException( + "The manual-financial write request is stale or belongs to another workflow state.", + nameof(request)); + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!await _writeSafety.WriteGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + throw new InvalidOperationException("Another manual-financial write is already in progress."); + } + + try + { + snapshot = RequireWritable(snapshot); + if (!_writeSafety.TryBeginWrite( + snapshot.WriteEpoch, + out var writeEpoch, + out var rejectionReason)) + { + throw new InvalidOperationException(rejectionReason); + } + + ManualFinancialMutationReceipt receipt; + try + { + receipt = request switch + { + LegacyManualFinancialCreateRequest create => + await _screenService.CreateAsync( + create.Record, + cancellationToken).ConfigureAwait(false), + LegacyManualFinancialUpdateRequest update => + await _screenService.UpdateAsync( + update.Expected, + update.Replacement, + cancellationToken).ConfigureAwait(false), + LegacyManualFinancialDeleteRequest delete => + await _screenService.DeleteAsync( + delete.Expected, + cancellationToken).ConfigureAwait(false), + LegacyManualFinancialDeleteAllRequest => + await _screenService.DeleteAllAsync( + snapshot.Screen, + cancellationToken).ConfigureAwait(false), + _ => throw new ArgumentOutOfRangeException(nameof(request)) + }; + } + catch (ManualFinancialMutationException exception) + { + return exception.OutcomeUnknown + ? Quarantine(snapshot, request, writeEpoch, exception.Message) + : Rejected(snapshot, request, writeEpoch, exception.Message); + } + catch (Exception exception) + { + return Quarantine( + snapshot, + request, + writeEpoch, + $"The {request.Kind} outcome is unknown: {exception.Message}"); + } + + if (!IsCorrelatedReceipt(receipt, request)) + { + return Quarantine( + snapshot, + request, + writeEpoch, + "The write response was malformed or not correlated; its outcome is unknown."); + } + + return request switch + { + LegacyManualFinancialCreateRequest or LegacyManualFinancialUpdateRequest => + await CompleteSaveAsync( + snapshot, + request, + receipt, + writeEpoch, + cancellationToken) + .ConfigureAwait(false), + LegacyManualFinancialDeleteRequest => + CompleteDelete(snapshot, request, receipt, writeEpoch), + LegacyManualFinancialDeleteAllRequest => + CompleteDeleteAll(snapshot, request, receipt, writeEpoch), + _ => throw new ArgumentOutOfRangeException(nameof(request)) + }; + } + finally + { + _writeSafety.WriteGate.Release(); + } + } + + public LegacyManualFinancialPlaylistDraft MaterializePlaylistDraft( + LegacyManualFinancialWorkflowSnapshot snapshot, + string actionId) + { + snapshot = RequireOwned(snapshot); + if (!ScreensByActionId.TryGetValue(actionId, out var definition) || + definition.Screen != snapshot.Screen) + { + throw new ArgumentException("The manual-financial action id is invalid.", nameof(actionId)); + } + + if (IsQuarantined(out _)) + { + throw new InvalidOperationException( + "Playlist materialization is blocked while a write outcome is unknown."); + } + + var detail = RequireFreshDetail(snapshot); + var verified = RequireVerifiedForDetail(snapshot, detail); + var cut = ManualFinancialCutContracts.CreateSelection(detail, verified); + return new LegacyManualFinancialPlaylistDraft( + definition.ActionId, + definition.BuilderKey, + definition.Code, + definition.Aliases, + detail.Record.Identity.StockName, + definition.Label, + "manual-financial", + MarketName(verified.Market), + verified.Code, + verified.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq, + cut, + CloneSnapshot(detail)); + } + + private async Task CompleteSaveAsync( + LegacyManualFinancialWorkflowSnapshot snapshot, + LegacyManualFinancialWriteRequest request, + ManualFinancialMutationReceipt receipt, + long writeEpoch, + CancellationToken cancellationToken) + { + var identity = new ManualFinancialIdentity(request.Screen, request.StockName); + ManualFinancialSnapshot detail; + try + { + detail = ValidateSnapshot( + await _screenService.GetAsync(identity, cancellationToken).ConfigureAwait(false), + request.Screen, + "post-save read-back"); + if (!EqualsIdentity(identity, detail.Record.Identity)) + { + throw InvalidData("The post-save read-back is not correlated to its write."); + } + } + catch (Exception exception) + { + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + SelectedRowId = null, + Detail = null, + IsDetailFresh = false, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.ReadBackRequired, + WritesQuarantined = false, + LastMessage = $"The write committed, but a fresh read-back is required: {exception.Message}", + LastRequestId = request.RequestId, + LastReceipt = receipt + }; + } + + var rows = UpsertRow(snapshot, detail, out var resultId); + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + Rows = rows, + SelectedRowId = resultId, + Detail = detail, + IsDetailFresh = true, + SelectedStockResultId = snapshot.SelectedStockResultId, + Verified = request.VerifiedStock, + VerifiedForRowVersion = detail.RowVersion, + Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, + WritesQuarantined = false, + LastMessage = null, + LastRequestId = request.RequestId, + LastReceipt = receipt + }; + } + + private static LegacyManualFinancialWorkflowSnapshot CompleteDelete( + LegacyManualFinancialWorkflowSnapshot snapshot, + LegacyManualFinancialWriteRequest request, + ManualFinancialMutationReceipt receipt, + long writeEpoch) + { + var rows = snapshot.Rows + .Where(row => !string.Equals( + row.StockName, + request.StockName, + StringComparison.Ordinal)) + .ToArray(); + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + Rows = Array.AsReadOnly(rows), + SelectedRowId = null, + Detail = null, + IsDetailFresh = false, + StockQuery = string.Empty, + StockCandidates = Array.Empty(), + StockSearchIsTruncated = false, + SelectedStockResultId = null, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, + WritesQuarantined = false, + LastMessage = null, + LastRequestId = request.RequestId, + LastReceipt = receipt + }; + } + + private static LegacyManualFinancialWorkflowSnapshot CompleteDeleteAll( + LegacyManualFinancialWorkflowSnapshot snapshot, + LegacyManualFinancialWriteRequest request, + ManualFinancialMutationReceipt receipt, + long writeEpoch) => + snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + Rows = Array.Empty(), + ListIsTruncated = false, + SelectedRowId = null, + Detail = null, + IsDetailFresh = false, + StockQuery = string.Empty, + StockCandidates = Array.Empty(), + StockSearchIsTruncated = false, + SelectedStockResultId = null, + Verified = null, + VerifiedForRowVersion = null, + Status = LegacyManualFinancialWorkflowStatus.WriteCommitted, + WritesQuarantined = false, + LastMessage = null, + LastRequestId = request.RequestId, + LastReceipt = receipt + }; + + private LegacyManualFinancialWorkflowSnapshot Rejected( + LegacyManualFinancialWorkflowSnapshot snapshot, + LegacyManualFinancialWriteRequest request, + long writeEpoch, + string message) => + snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + Status = LegacyManualFinancialWorkflowStatus.WriteRejected, + WritesQuarantined = false, + LastMessage = message, + LastRequestId = request.RequestId, + LastReceipt = null + }; + + private LegacyManualFinancialWorkflowSnapshot Quarantine( + LegacyManualFinancialWorkflowSnapshot snapshot, + LegacyManualFinancialWriteRequest request, + long writeEpoch, + string message) + { + message = _writeSafety.Quarantine(message); + + return snapshot with + { + Revision = checked(snapshot.Revision + 1), + WriteEpoch = writeEpoch, + Status = LegacyManualFinancialWorkflowStatus.OutcomeUnknown, + WritesQuarantined = true, + LastMessage = message, + LastRequestId = request.RequestId, + LastReceipt = null + }; + } + + private LegacyManualFinancialWorkflowSnapshot RequireOwned( + LegacyManualFinancialWorkflowSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + if (!ReferenceEquals(snapshot.Authority, _authority) || + !ReferenceEquals(snapshot.Definition, GetScreen(snapshot.Screen))) + { + throw new ArgumentException( + "The workflow snapshot belongs to another manual-financial workflow.", + nameof(snapshot)); + } + + if (!_writeSafety.IsCurrent(snapshot.WriteEpoch)) + { + throw new InvalidOperationException( + "The manual-financial workflow snapshot is stale after a write attempt."); + } + + return snapshot; + } + + private LegacyManualFinancialWorkflowSnapshot RequireCurrentRevision( + LegacyManualFinancialWorkflowSnapshot snapshot, + long expectedRevision) + { + snapshot = RequireOwned(snapshot); + if (expectedRevision < 0 || snapshot.Revision != expectedRevision) + { + throw new InvalidOperationException( + "The manual-financial list command belongs to a stale view generation."); + } + + return snapshot; + } + + private LegacyManualFinancialWorkflowSnapshot RequireWritable( + LegacyManualFinancialWorkflowSnapshot snapshot) + { + snapshot = RequireOwned(snapshot); + if (!_screenService.CanMutate) + { + throw new InvalidOperationException( + "Manual-financial screens are configured for read-only access."); + } + + if (IsQuarantined(out var reason)) + { + throw new InvalidOperationException( + $"Manual-financial writes are quarantined: {reason}"); + } + + return snapshot; + } + + private bool IsQuarantined(out string? reason) => + _writeSafety.IsQuarantined(out reason); + + private static ManualFinancialSnapshot RequireFreshDetail( + LegacyManualFinancialWorkflowSnapshot snapshot) => + snapshot.IsDetailFresh && snapshot.Detail is not null + ? snapshot.Detail + : throw new InvalidOperationException( + "A fresh manual-financial detail read is required."); + + private static VerifiedManualFinancialStock RequireVerifiedForDetail( + LegacyManualFinancialWorkflowSnapshot snapshot, + ManualFinancialSnapshot detail) + { + if (snapshot.Verified is null || + !string.Equals( + snapshot.VerifiedForRowVersion, + detail.RowVersion, + StringComparison.Ordinal) || + !string.Equals( + snapshot.Verified.Name, + detail.Record.Identity.StockName, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "The selected detail requires a fresh exact stock-master verification."); + } + + return snapshot.Verified; + } + + private static LegacyManualFinancialListRow FindRow( + IReadOnlyList rows, + string resultId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resultId); + return rows.SingleOrDefault(row => + string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) ?? + throw new ArgumentException("The manual-financial result id is invalid.", nameof(resultId)); + } + + private static LegacyManualFinancialStockRow FindStock( + IReadOnlyList rows, + string resultId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resultId); + return rows.SingleOrDefault(row => + string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) ?? + throw new ArgumentException("The stock result id is invalid.", nameof(resultId)); + } + + private static IReadOnlyList ReplaceRow( + IReadOnlyList rows, + string resultId, + ManualFinancialSnapshot detail) => + Array.AsReadOnly(rows.Select(row => + string.Equals(row.ResultId, resultId, StringComparison.Ordinal) + ? new LegacyManualFinancialListRow(resultId, detail) + : row) + .ToArray()); + + private static IReadOnlyList UpsertRow( + LegacyManualFinancialWorkflowSnapshot snapshot, + ManualFinancialSnapshot detail, + out string resultId) + { + var existing = snapshot.Rows.SingleOrDefault(row => string.Equals( + row.StockName, + detail.Record.Identity.StockName, + StringComparison.Ordinal)); + resultId = existing?.ResultId ?? NewOpaqueId("mf-row"); + var rows = snapshot.Rows + .Where(row => !string.Equals( + row.StockName, + detail.Record.Identity.StockName, + StringComparison.Ordinal)) + .ToList(); + if (snapshot.Query.Length == 0 || + detail.Record.Identity.StockName.Contains( + snapshot.Query, + StringComparison.OrdinalIgnoreCase)) + { + rows.Add(new LegacyManualFinancialListRow(resultId, detail)); + } + + return SortRows(rows, snapshot.NameSortDirection); + } + + private static IReadOnlyList SortRows( + IEnumerable rows, + LegacyManualFinancialNameSortDirection direction) + { + if (!Enum.IsDefined(direction)) + { + throw new ArgumentOutOfRangeException(nameof(direction)); + } + + var result = rows.ToList(); + result.Sort((left, right) => + { + var compared = StringComparer.Ordinal.Compare(left.StockName, right.StockName); + return direction == LegacyManualFinancialNameSortDirection.Ascending + ? compared + : -compared; + }); + return Array.AsReadOnly(result.ToArray()); + } + + private static bool IsCorrelatedReceipt( + ManualFinancialMutationReceipt? receipt, + LegacyManualFinancialWriteRequest request) => + receipt is not null && + receipt.OperationId != Guid.Empty && + receipt.Kind == request.Kind && + receipt.Screen == request.Screen && + string.Equals(receipt.StockName, request.StockName, StringComparison.Ordinal) && + (request.Kind == ManualFinancialMutationKind.DeleteAll + ? receipt.AffectedRows >= 0 + : receipt.AffectedRows == 1); + + private static ManualFinancialSnapshot ValidateSnapshot( + ManualFinancialSnapshot? snapshot, + ManualFinancialScreenKind screen, + string source) + { + try + { + ArgumentNullException.ThrowIfNull(snapshot); + var copy = CloneSnapshot(snapshot); + if (copy.Record.Identity.Screen != screen) + { + throw new ArgumentException("The response screen does not match."); + } + + var validationStock = ValidationStock(copy.Record.Identity); + _ = ManualFinancialCutContracts.CreateSelection(copy, validationStock); + return copy; + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + throw InvalidData($"The manual-financial {source} is invalid: {exception.Message}"); + } + } + + private static ManualFinancialRecord ValidateRecord( + ManualFinancialRecord? record, + ManualFinancialScreenKind screen, + VerifiedManualFinancialStock stock, + string parameterName) + { + ArgumentNullException.ThrowIfNull(record, parameterName); + var copy = CloneRecord(record); + if (copy.Identity.Screen != screen) + { + throw new ArgumentException( + "The manual-financial record does not match its screen.", + parameterName); + } + + try + { + _ = ManualFinancialCutContracts.CreateSelection( + new ManualFinancialSnapshot(copy, ValidationRowVersion), + stock); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + throw new ArgumentException( + $"The manual-financial record is invalid: {exception.Message}", + parameterName, + exception); + } + + return copy; + } + + private static ManualFinancialSnapshot CloneSnapshot(ManualFinancialSnapshot snapshot) => + new(CloneRecord(snapshot.Record), snapshot.RowVersion); + + private static ManualFinancialRecord CloneRecord(ManualFinancialRecord record) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(record.Identity); + var identity = new ManualFinancialIdentity( + record.Identity.Screen, + record.Identity.StockName); + return record switch + { + ManualRevenueCompositionRecord revenue => + new ManualRevenueCompositionRecord( + identity, + revenue.BaseDate, + Array.AsReadOnly((revenue.Slices ?? throw new ArgumentException( + "Revenue slices are required.", + nameof(record))) + .Select(slice => slice is null + ? null + : new ManualRevenueSlice( + slice.Label, + slice.PercentageText, + slice.Percentage)) + .ToArray())), + ManualGrowthMetricsRecord growth => + new ManualGrowthMetricsRecord( + identity, + CloneGrowth(growth.SalesGrowth), + CloneGrowth(growth.OperatingProfitGrowth), + CloneGrowth(growth.NetAssetGrowth), + CloneGrowth(growth.NetIncomeGrowth), + ClonePeriods(growth.Periods)), + ManualSalesRecord sales => + new ManualSalesRecord(identity, CloneQuarters(sales.Quarters)), + ManualOperatingProfitRecord profit => + new ManualOperatingProfitRecord(identity, CloneQuarters(profit.Quarters)), + _ => throw new ArgumentException( + "The manual-financial record type is invalid.", + nameof(record)) + }; + } + + private static ManualGrowthSeriesValues CloneGrowth(ManualGrowthSeriesValues? values) + { + ArgumentNullException.ThrowIfNull(values); + return new ManualGrowthSeriesValues( + values.First, + values.Second, + values.Third, + values.Fourth); + } + + private static ManualGrowthPeriodLabels ClonePeriods(ManualGrowthPeriodLabels? periods) + { + ArgumentNullException.ThrowIfNull(periods); + return new ManualGrowthPeriodLabels( + periods.First, + periods.Second, + periods.Third, + periods.Fourth); + } + + private static IReadOnlyList CloneQuarters( + IReadOnlyList? quarters) + { + ArgumentNullException.ThrowIfNull(quarters); + return Array.AsReadOnly(quarters.Select(quarter => + quarter is null + ? throw new ArgumentException("A manual-financial quarter is invalid.") + : new ManualQuarterValue(quarter.Quarter, quarter.Value)) + .ToArray()); + } + + private static VerifiedManualFinancialStock ValidationStock( + ManualFinancialIdentity identity) + { + var candidate = new StockSearchItem( + StockMarket.Kospi, + DataSourceKind.Oracle, + identity.StockName, + ValidationStockCode); + return ManualFinancialCutContracts.VerifyStock( + identity, + [candidate], + candidate.Market, + candidate.Code); + } + + private static void ValidateStockItem(StockSearchItem? item) + { + if (item is null) + { + throw InvalidData("The stock-search response contains a null item."); + } + + try + { + var identity = new ManualFinancialIdentity( + ManualFinancialScreenKind.Sales, + item.Name); + _ = ManualFinancialCutContracts.VerifyStock( + identity, + [item], + item.Market, + item.Code); + } + catch (Exception exception) when (exception is ArgumentException or InvalidOperationException) + { + throw InvalidData($"The stock-search response is invalid: {exception.Message}"); + } + } + + private static string NormalizeQuery(string value, bool allowEmpty) + { + ArgumentNullException.ThrowIfNull(value); + var normalized = value.Trim(); + if ((!allowEmpty && normalized.Length == 0) || normalized.Length > 64 || + normalized.Any(static character => + char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD')) + { + throw new ArgumentException("The search text is invalid.", nameof(value)); + } + + return normalized; + } + + private static bool EqualsIdentity( + ManualFinancialIdentity left, + ManualFinancialIdentity right) => + left.Screen == right.Screen && + string.Equals(left.StockName, right.StockName, StringComparison.Ordinal); + + private static string MarketName(StockMarket market) => market switch + { + StockMarket.Kospi => "KOSPI", + StockMarket.Kosdaq => "KOSDAQ", + StockMarket.NxtKospi => "NXT_KOSPI", + StockMarket.NxtKosdaq => "NXT_KOSDAQ", + _ => throw new ArgumentOutOfRangeException(nameof(market)) + }; + + private static string NewOpaqueId(string prefix) => + prefix + "-" + Guid.NewGuid().ToString("N"); + + private static LegacyManualFinancialWorkflowDataException InvalidData(string message) => + new(message); + + private static IReadOnlyList CreateDefinitions() => + Array.AsReadOnly( + [ + new( + ManualFinancialScreenKind.RevenueComposition, + "revenue-composition", + "주요매출 구성", + "INPUT_PIE", + "s5076", + "5076", + "REVENUE_COMPOSITION", + "DELETE_ALL_INPUT_PIE"), + new( + ManualFinancialScreenKind.GrowthMetrics, + "growth-metrics", + "성장성 지표", + "INPUT_GROW", + "s5079", + "5079", + "GROWTH_METRICS", + "DELETE_ALL_INPUT_GROW"), + new( + ManualFinancialScreenKind.Sales, + "sales", + "매출액", + "INPUT_SELL", + "s5080", + "5080", + "SALES", + "DELETE_ALL_INPUT_SELL"), + new( + ManualFinancialScreenKind.OperatingProfit, + "operating-profit", + "영업이익", + "INPUT_PROFIT", + "s5081", + "5081", + "OPERATING_PROFIT", + "DELETE_ALL_INPUT_PROFIT") + ]); + + private static void ValidateDefinitions( + IReadOnlyList definitions) + { + if (definitions.Count != 4 || + definitions.Select(definition => definition.Screen).Distinct().Count() != 4 || + definitions.Select(definition => definition.ActionId) + .Distinct(StringComparer.Ordinal).Count() != 4) + { + throw new InvalidOperationException( + "The manual-financial screen registry is incomplete or ambiguous."); + } + + foreach (var definition in definitions) + { + var core = ManualFinancialCutContracts.Get(definition.Screen); + if (!string.Equals(core.TableName, definition.TableName, StringComparison.Ordinal) || + !string.Equals(core.BuilderKey, definition.BuilderKey, StringComparison.Ordinal) || + !string.Equals(core.CutCode, definition.Code, StringComparison.Ordinal) || + !string.Equals( + core.CanonicalGraphicType, + definition.CanonicalGraphicType, + StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"The {definition.Screen} application contract differs from Core."); + } + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs new file mode 100644 index 0000000..206e675 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/ManualLists/LegacyManualListsWorkflow.cs @@ -0,0 +1,974 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyManualListScreen +{ + NetSell, + Vi +} + +public enum LegacyManualNetSellField +{ + LeftName, + LeftAmount, + RightName, + RightAmount +} + +public enum LegacyManualListMoveDirection +{ + Up, + Down +} + +public sealed record LegacyManualListStoreAvailability( + bool IsAvailable, + string Code, + string? Message); + +public sealed record LegacyManualViStoredItem(string Code, string Name); + +public sealed record LegacyManualViStoreSnapshot( + IReadOnlyList Items, + string RowVersion); + +public sealed record LegacyManualViWriteReceipt( + LegacyManualViStoreSnapshot Snapshot, + bool Persisted); + +public sealed record LegacyManualImportReceipt( + int MarkerVersion, + string SourceSha256, + DateTimeOffset ImportedAt, + int NetSellRowCount, + int ViItemCount); + +public sealed class LegacyManualImportStoreException : Exception +{ + public LegacyManualImportStoreException(string code, bool outcomeUnknown) + : base("The legacy manual-data import did not complete.") + { + Code = code; + OutcomeUnknown = outcomeUnknown; + } + + public string Code { get; } + + public bool OutcomeUnknown { get; } +} + +/// +/// Native-only boundary for the operator-owned CP949 files. Implementations choose +/// every path and file name; the WebView can only select a closed screen or opaque row. +/// +public interface ILegacyManualListStore +{ + LegacyManualListStoreAvailability Availability { get; } + + Task> ReadNetSellAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default); + + Task WriteNetSellAsync( + S5025ManualAudience audience, + IReadOnlyList rows, + CancellationToken cancellationToken = default); + + Task ReadViAsync( + CancellationToken cancellationToken = default); + + Task WriteViAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default); + + Task ImportAsync( + CancellationToken cancellationToken = default); +} + +public sealed record LegacyManualNetSellRowView( + string RowId, + string LeftName, + string LeftAmount, + string RightName, + string RightAmount); + +public sealed record LegacyManualViItemView( + string ItemId, + int Index, + string Name); + +public sealed record LegacyManualViSearchResultView( + string ResultId, + string DisplayName, + string MarketText); + +public sealed record LegacyManualListsSnapshot( + bool IsOpen, + LegacyManualListScreen? Screen, + S5025ManualAudience Audience, + bool IsAvailable, + string AvailabilityCode, + string? AvailabilityMessage, + bool WritesQuarantined, + IReadOnlyList NetRows, + bool NetRowsAreFresh, + IReadOnlyList ViItems, + int ViPageCount, + bool ViItemsAreFresh, + string SearchQuery, + IReadOnlyList SearchResults, + string StatusMessage, + LegacyOperatorStatusKind StatusKind, + bool CanSave, + bool CanMaterializePlaylist) +{ + public string? SelectedSearchResultId { get; init; } +} + +public sealed record LegacyManualListPlaylistDraft( + string SceneAlias, + string MarketText, + string StockName, + string GraphicType, + string Subtype, + string PageText, + string RawCutLabel, + int FadeDuration, + LegacySceneSelection Selection); + +/// +/// Authoritative FSell/VIList workflow. Draft rows, stock identities, file versions, +/// save verification and playlist materialization remain in C#; browser messages are +/// limited to input intent and opaque identifiers. +/// +public sealed class LegacyManualListsWorkflow +{ + public const int NetSellRowCount = 5; + public const int MaximumViItems = 100; + public const int ViPageSize = 5; + public const int MaximumPageCount = 20; + public const int MaximumNetSellValueLength = 256; + public const int MaximumSearchLength = 100; + + private const string ViSnapshotSubject = "@MBN_VI_SNAPSHOT_V1"; + private const string OutcomeUnknownMessage = + "저장 결과를 확인할 수 없어 수동 목록 쓰기를 격리했습니다. 앱을 다시 시작하고 파일을 확인하세요."; + + private readonly ILegacyManualListStore _store; + private readonly ILegacyStockLookup _stockLookup; + private readonly List _netRows = CreateEmptyNetRows(); + private readonly List _viItems = []; + private readonly List _searchResults = []; + private LegacyManualListScreen? _screen; + private S5025ManualAudience _audience = S5025ManualAudience.Individual; + private bool _isOpen; + private bool _netRowsAreFresh; + private bool _viItemsAreFresh; + private bool _writesQuarantined; + private string? _viVersion; + private string _searchQuery = string.Empty; + private string? _selectedSearchResultId; + private string _statusMessage = string.Empty; + private LegacyOperatorStatusKind _statusKind; + private long _opaqueSequence; + + public LegacyManualListsWorkflow( + ILegacyManualListStore store, + ILegacyStockLookup stockLookup) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _stockLookup = stockLookup ?? throw new ArgumentNullException(nameof(stockLookup)); + } + + public LegacyManualListsSnapshot Current => CreateSnapshot(); + + public async Task OpenNetSellAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) + { + if (!Enum.IsDefined(audience)) + { + throw new ArgumentOutOfRangeException(nameof(audience)); + } + + _isOpen = true; + _screen = LegacyManualListScreen.NetSell; + _audience = audience; + _searchResults.Clear(); + _searchQuery = string.Empty; + _selectedSearchResultId = null; + await ReadNetSellAsync(cancellationToken).ConfigureAwait(false); + return Current; + } + + public async Task OpenViAsync( + CancellationToken cancellationToken = default) + { + _isOpen = true; + _screen = LegacyManualListScreen.Vi; + _searchResults.Clear(); + _searchQuery = string.Empty; + _selectedSearchResultId = null; + await ReadViAsync(cancellationToken).ConfigureAwait(false); + return Current; + } + + public LegacyManualListsSnapshot Close() + { + _isOpen = false; + _screen = null; + _searchResults.Clear(); + _searchQuery = string.Empty; + _selectedSearchResultId = null; + SetStatus(string.Empty, LegacyOperatorStatusKind.Neutral); + return Current; + } + + public async Task RefreshAsync( + CancellationToken cancellationToken = default) + { + if (_screen == LegacyManualListScreen.NetSell) + { + await ReadNetSellAsync(cancellationToken).ConfigureAwait(false); + } + else if (_screen == LegacyManualListScreen.Vi) + { + await ReadViAsync(cancellationToken).ConfigureAwait(false); + } + + return Current; + } + + public LegacyManualListsSnapshot SetNetSellCell( + string rowId, + LegacyManualNetSellField field, + string value) + { + ArgumentNullException.ThrowIfNull(rowId); + ArgumentNullException.ThrowIfNull(value); + if (_screen != LegacyManualListScreen.NetSell || + !TryParseNetRowId(rowId, out var index) || + !IsSafeNetValue(value) || + !Enum.IsDefined(field)) + { + SetStatus("수동 순매도 입력값을 확인하세요.", LegacyOperatorStatusKind.Warning); + return Current; + } + + var row = _netRows[index]; + _netRows[index] = field switch + { + LegacyManualNetSellField.LeftName => row with { LeftName = value }, + LegacyManualNetSellField.LeftAmount => row with { LeftAmount = value }, + LegacyManualNetSellField.RightName => row with { RightName = value }, + LegacyManualNetSellField.RightAmount => row with { RightAmount = value }, + _ => row + }; + _netRowsAreFresh = false; + SetStatus("변경 내용을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.", + LegacyOperatorStatusKind.Information); + return Current; + } + + public async Task SaveNetSellAsync( + CancellationToken cancellationToken = default) + { + if (!CanWrite(LegacyManualListScreen.NetSell)) + { + return Current; + } + + var expected = _netRows.ToArray(); + try + { + await _store.WriteNetSellAsync(_audience, expected, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return await RecoverNetSellWriteAsync(expected, cancellationToken) + .ConfigureAwait(false); + } + + return await VerifyNetSellWriteAsync(expected, recovered: false, cancellationToken) + .ConfigureAwait(false); + } + + public async Task SearchViAsync( + string query, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(query); + _searchQuery = query.Trim(); + _searchResults.Clear(); + _selectedSearchResultId = null; + if (_screen != LegacyManualListScreen.Vi || + _searchQuery.Length == 0 || + _searchQuery.Length > MaximumSearchLength) + { + SetStatus("검색할 종목명을 입력하세요.", LegacyOperatorStatusKind.Warning); + return Current; + } + + try + { + var data = await _stockLookup.SearchAsync(_searchQuery, cancellationToken) + .ConfigureAwait(false); + foreach (var displayName in data.DisplayNames.Take(200)) + { + var identity = data.ResolveLastByDisplayName(displayName); + if (identity is not null) + { + _searchResults.Add(new ViSearchResult( + NextOpaqueId("manual-vi-result"), + identity)); + } + } + + SetStatus( + _searchResults.Count == 0 + ? "검색 결과가 없습니다." + : $"{_searchResults.Count}개 종목을 찾았습니다.", + LegacyOperatorStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + SetStatus("종목 검색을 완료하지 못했습니다.", LegacyOperatorStatusKind.Error); + } + + return Current; + } + + public LegacyManualListsSnapshot SelectViSearchResult(string resultId) + { + ArgumentNullException.ThrowIfNull(resultId); + if (_screen == LegacyManualListScreen.Vi && _searchResults.Any(item => + string.Equals(item.ResultId, resultId, StringComparison.Ordinal))) + { + _selectedSearchResultId = resultId; + } + else + { + SetStatus( + "The selected stock is no longer in the current VI search generation.", + LegacyOperatorStatusKind.Warning); + } + + return Current; + } + + public LegacyManualListsSnapshot AddViSearchResult(string resultId) + { + ArgumentNullException.ThrowIfNull(resultId); + var result = _searchResults.SingleOrDefault(item => + string.Equals(item.ResultId, resultId, StringComparison.Ordinal)); + if (_screen != LegacyManualListScreen.Vi || + result is null || + _viItems.Count >= MaximumViItems) + { + SetStatus("선택한 종목을 VI 목록에 추가할 수 없습니다.", + LegacyOperatorStatusKind.Warning); + return Current; + } + + _selectedSearchResultId = resultId; + + var prefix = result.Identity.Market is + LegacyDomesticStockMarket.Kospi or LegacyDomesticStockMarket.NxtKospi + ? "P" + : "D"; + var item = new LegacyManualViStoredItem( + prefix + result.Identity.StockCode, + result.Identity.DisplayName); + if (!IsValidViItem(item)) + { + SetStatus("선택한 종목 정보가 VI 파일 형식에 맞지 않습니다.", + LegacyOperatorStatusKind.Warning); + return Current; + } + + _viItems.Add(new ViDraftItem(NextOpaqueId("manual-vi-item"), item)); + _viItemsAreFresh = false; + SetStatus("VI 목록을 저장한 뒤 재조회해야 컷을 추가할 수 있습니다.", + LegacyOperatorStatusKind.Information); + return Current; + } + + public LegacyManualListsSnapshot MoveViItem( + string itemId, + LegacyManualListMoveDirection direction) + { + var index = FindViItem(itemId); + var target = direction == LegacyManualListMoveDirection.Up ? index - 1 : index + 1; + if (_screen != LegacyManualListScreen.Vi || + index < 0 || target < 0 || target >= _viItems.Count || + !Enum.IsDefined(direction)) + { + return Current; + } + + (_viItems[index], _viItems[target]) = (_viItems[target], _viItems[index]); + _viItemsAreFresh = false; + SetStatus("VI 순서를 변경했습니다. 저장 후 재조회하세요.", + LegacyOperatorStatusKind.Information); + return Current; + } + + public LegacyManualListsSnapshot DeleteViItem(string itemId) + { + var index = FindViItem(itemId); + if (_screen == LegacyManualListScreen.Vi && index >= 0) + { + _viItems.RemoveAt(index); + _viItemsAreFresh = false; + SetStatus("VI 종목을 삭제했습니다. 저장 후 재조회하세요.", + LegacyOperatorStatusKind.Information); + } + + return Current; + } + + public LegacyManualListsSnapshot DeleteAllViItems() + { + if (_screen == LegacyManualListScreen.Vi && _viItems.Count > 0) + { + _viItems.Clear(); + _viItemsAreFresh = false; + SetStatus("VI 목록을 비웠습니다. 원본과 같이 빈 목록 저장은 기존 파일을 유지합니다.", + LegacyOperatorStatusKind.Information); + } + + return Current; + } + + public async Task SaveViAsync( + CancellationToken cancellationToken = default) + { + if (!CanWrite(LegacyManualListScreen.Vi) || + !TrustedViManualReference.IsRowVersion(_viVersion)) + { + return Current; + } + + var expectedVersion = _viVersion; + var expectedItems = _viItems.Select(item => item.Stored).ToArray(); + LegacyManualViStoreSnapshot preflight; + try + { + preflight = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + SetStatus("VI 저장 전 파일 버전을 확인하지 못했습니다. 저장하지 않았습니다.", + LegacyOperatorStatusKind.Error); + return Current; + } + + if (!string.Equals(preflight.RowVersion, expectedVersion, StringComparison.Ordinal)) + { + SetStatus("VI 파일이 다른 작업에서 변경되었습니다. 재조회 후 다시 편집하세요.", + LegacyOperatorStatusKind.Warning); + return Current; + } + + LegacyManualViWriteReceipt receipt; + try + { + receipt = await _store.WriteViAsync(expectedItems, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + return await RecoverViWriteAsync(expectedItems, cancellationToken) + .ConfigureAwait(false); + } + + return await VerifyViWriteAsync( + expectedItems, + receipt, + recovered: false, + cancellationToken).ConfigureAwait(false); + } + + public async Task ImportAsync( + CancellationToken cancellationToken = default) + { + if (!_store.Availability.IsAvailable || _writesQuarantined) + { + return Current; + } + + try + { + var receipt = await _store.ImportAsync(cancellationToken).ConfigureAwait(false); + SetStatus( + $"원본 수동 데이터를 가져왔습니다. 순매도 {receipt.NetSellRowCount}행, VI {receipt.ViItemCount}종목.", + LegacyOperatorStatusKind.Information); + return await RefreshAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (LegacyManualImportStoreException exception) + { + if (exception.OutcomeUnknown) + { + QuarantineWrites(); + } + else + { + SetStatus(ImportFailureMessage(exception.Code), LegacyOperatorStatusKind.Warning); + } + } + catch + { + SetStatus("원본 수동 데이터 가져오기를 완료하지 못했습니다.", + LegacyOperatorStatusKind.Error); + } + + return Current; + } + + public LegacyManualListPlaylistDraft CreateNetSellPlaylistDraft() + { + if (_screen != LegacyManualListScreen.NetSell || !_netRowsAreFresh) + { + throw new InvalidOperationException( + "The net-sell rows must be saved and re-read before materialization."); + } + + var (groupCode, label) = _audience switch + { + S5025ManualAudience.Individual => ("INDIVIDUAL", "개인"), + S5025ManualAudience.Foreign => ("FOREIGN", "외국인"), + S5025ManualAudience.Institution => ("INSTITUTION", "기관"), + _ => throw new InvalidOperationException("The manual audience is invalid.") + }; + return new LegacyManualListPlaylistDraft( + "5025", + "지수", + $"{label} 순매도 상위(수동)", + "순매도 상위", + "순매도 상위", + "1/1", + $"{label} 순매도 상위(수동)", + 6, + new LegacySceneSelection( + groupCode, + "INDEX", + "MANUAL_NET_SELL", + "MANUAL_NET_SELL", + string.Empty)); + } + + public LegacyManualListPlaylistDraft CreateViPlaylistDraft() + { + if (_screen != LegacyManualListScreen.Vi || + !_viItemsAreFresh || + _viItems.Count == 0 || + !TrustedViManualReference.IsRowVersion(_viVersion)) + { + throw new InvalidOperationException( + "The VI rows must be saved and re-read before materialization."); + } + + var names = string.Join(",", _viItems.Select(item => item.Stored.Name)); + var pageCount = (_viItems.Count + ViPageSize - 1) / ViPageSize; + var rowVersion = _viVersion ?? throw new InvalidOperationException( + "The trusted VI row version is unavailable."); + return new LegacyManualListPlaylistDraft( + "5074", + "지수", + names, + "5단 표그래프", + "VI 발동(수동)", + $"1/{pageCount}", + "5단 표그래프_VI 발동(수동)", + 6, + new LegacySceneSelection( + "PAGED_VI", + ViSnapshotSubject, + "INPUT_ORDER", + "CURRENT", + rowVersion)); + } + + private async Task ReadNetSellAsync(CancellationToken cancellationToken) + { + _netRowsAreFresh = false; + if (!EnsureAvailable()) + { + return; + } + + try + { + var rows = await _store.ReadNetSellAsync(_audience, cancellationToken) + .ConfigureAwait(false); + if (rows.Count != NetSellRowCount || rows.Any(row => !IsValidNetRow(row))) + { + throw new InvalidDataException("The FSell file shape is invalid."); + } + + _netRows.Clear(); + _netRows.AddRange(rows); + _netRowsAreFresh = true; + SetStatus("수동 순매도 5행을 조회했습니다.", LegacyOperatorStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + _netRows.Clear(); + _netRows.AddRange(CreateEmptyNetRows()); + SetStatus("수동 순매도 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.", + LegacyOperatorStatusKind.Error); + } + } + + private async Task ReadViAsync(CancellationToken cancellationToken) + { + _viItemsAreFresh = false; + _viVersion = null; + if (!EnsureAvailable()) + { + return; + } + + try + { + var snapshot = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false); + SetViSnapshot(snapshot); + SetStatus($"VI 수동 목록 {snapshot.Items.Count}종목을 조회했습니다.", + LegacyOperatorStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + _viItems.Clear(); + SetStatus("VI 수동 목록 파일을 읽지 못했습니다. 필요하면 원본 가져오기를 실행하세요.", + LegacyOperatorStatusKind.Error); + } + } + + private async Task RecoverNetSellWriteAsync( + IReadOnlyList expected, + CancellationToken cancellationToken) + { + try + { + var actual = await _store.ReadNetSellAsync(_audience, cancellationToken) + .ConfigureAwait(false); + if (actual.SequenceEqual(expected)) + { + SetNetRows(actual); + SetStatus("저장 응답은 실패했지만 재조회로 동일한 5행을 확인했습니다.", + LegacyOperatorStatusKind.Warning); + return Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + } + + QuarantineWrites(); + return Current; + } + + private async Task VerifyNetSellWriteAsync( + IReadOnlyList expected, + bool recovered, + CancellationToken cancellationToken) + { + try + { + var actual = await _store.ReadNetSellAsync(_audience, cancellationToken) + .ConfigureAwait(false); + if (actual.SequenceEqual(expected)) + { + SetNetRows(actual); + SetStatus( + recovered + ? "재조회로 수동 순매도 저장을 확인했습니다." + : "저장 후 재조회한 5행이 입력값과 일치합니다.", + LegacyOperatorStatusKind.Information); + return Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + } + + QuarantineWrites(); + return Current; + } + + private async Task RecoverViWriteAsync( + IReadOnlyList expected, + CancellationToken cancellationToken) + { + try + { + var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false); + if (expected.Count > 0 && actual.Items.SequenceEqual(expected)) + { + SetViSnapshot(actual); + SetStatus("저장 응답은 실패했지만 재조회로 같은 VI 목록을 확인했습니다.", + LegacyOperatorStatusKind.Warning); + return Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + } + + QuarantineWrites(); + return Current; + } + + private async Task VerifyViWriteAsync( + IReadOnlyList expected, + LegacyManualViWriteReceipt receipt, + bool recovered, + CancellationToken cancellationToken) + { + try + { + var actual = await _store.ReadViAsync(cancellationToken).ConfigureAwait(false); + var receiptMatches = actual.Items.SequenceEqual(receipt.Snapshot.Items) && + string.Equals(actual.RowVersion, receipt.Snapshot.RowVersion, + StringComparison.Ordinal); + var expectedMatches = !receipt.Persisted || actual.Items.SequenceEqual(expected); + if (receiptMatches && expectedMatches && + TrustedViManualReference.IsRowVersion(actual.RowVersion)) + { + SetViSnapshot(actual); + SetStatus( + receipt.Persisted + ? recovered + ? "재조회로 VI 저장을 확인했습니다." + : "VI 저장 후 재조회한 목록과 버전이 일치합니다." + : "빈 목록은 원본 동작과 같이 파일을 덮어쓰지 않아 기존 목록을 다시 표시합니다.", + LegacyOperatorStatusKind.Information); + return Current; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + } + + QuarantineWrites(); + return Current; + } + + private bool CanWrite(LegacyManualListScreen requiredScreen) + { + if (_screen != requiredScreen || !_isOpen || !EnsureAvailable() || _writesQuarantined) + { + return false; + } + + return true; + } + + private bool EnsureAvailable() + { + if (_store.Availability.IsAvailable) + { + return true; + } + + SetStatus( + _store.Availability.Message ?? "수동 목록 저장소를 사용할 수 없습니다.", + LegacyOperatorStatusKind.Error); + return false; + } + + private void SetNetRows(IEnumerable rows) + { + _netRows.Clear(); + _netRows.AddRange(rows); + _netRowsAreFresh = true; + } + + private void SetViSnapshot(LegacyManualViStoreSnapshot snapshot) + { + if (snapshot.Items.Count > MaximumViItems || + snapshot.Items.Any(item => !IsValidViItem(item)) || + !TrustedViManualReference.IsRowVersion(snapshot.RowVersion)) + { + throw new InvalidDataException("The VI snapshot is invalid."); + } + + _viItems.Clear(); + _viItems.AddRange(snapshot.Items.Select(item => + new ViDraftItem(NextOpaqueId("manual-vi-item"), item))); + _viVersion = snapshot.RowVersion; + _viItemsAreFresh = true; + } + + private LegacyManualListsSnapshot CreateSnapshot() + { + var availability = _store.Availability; + var netRows = _netRows.Select((row, index) => new LegacyManualNetSellRowView( + NetRowId(index), + row.LeftName, + row.LeftAmount, + row.RightName, + row.RightAmount)).ToArray(); + var viItems = _viItems.Select((item, index) => new LegacyManualViItemView( + item.ItemId, + index + 1, + item.Stored.Name)).ToArray(); + var search = _searchResults.Select(item => new LegacyManualViSearchResultView( + item.ResultId, + item.Identity.DisplayName, + item.Identity.MarketText)).ToArray(); + var canSave = _isOpen && availability.IsAvailable && !_writesQuarantined && + (_screen == LegacyManualListScreen.NetSell || + (_screen == LegacyManualListScreen.Vi && + TrustedViManualReference.IsRowVersion(_viVersion))); + var canMaterialize = _screen switch + { + LegacyManualListScreen.NetSell => _netRowsAreFresh, + LegacyManualListScreen.Vi => _viItemsAreFresh && _viItems.Count > 0 && + TrustedViManualReference.IsRowVersion(_viVersion), + _ => false + }; + return new LegacyManualListsSnapshot( + _isOpen, + _screen, + _audience, + availability.IsAvailable, + availability.Code, + availability.Message, + _writesQuarantined, + Array.AsReadOnly(netRows), + _netRowsAreFresh, + Array.AsReadOnly(viItems), + (_viItems.Count + ViPageSize - 1) / ViPageSize, + _viItemsAreFresh, + _searchQuery, + Array.AsReadOnly(search), + _statusMessage, + _statusKind, + canSave, + canMaterialize) + { + SelectedSearchResultId = _selectedSearchResultId + }; + } + + private static List CreateEmptyNetRows() => + Enumerable.Range(0, NetSellRowCount) + .Select(_ => new S5025TrustedManualRow(string.Empty, string.Empty, string.Empty, string.Empty)) + .ToList(); + + private static bool IsValidNetRow(S5025TrustedManualRow row) => + row is not null && + IsSafeNetValue(row.LeftName) && + IsSafeNetValue(row.LeftAmount) && + IsSafeNetValue(row.RightName) && + IsSafeNetValue(row.RightAmount); + + private static bool IsSafeNetValue(string? value) => + value is not null && + value.Length <= MaximumNetSellValueLength && + !value.Contains('^') && + !value.Any(character => char.IsControl(character) || character == '\uFFFD'); + + private static bool IsValidViItem(LegacyManualViStoredItem item) => + item is not null && + IsSafeViText(item.Code, 64) && + IsSafeViText(item.Name, 200) && + !item.Name.Contains(','); + + private static bool IsSafeViText(string? value, int maximumLength) => + value is { Length: > 0 } && + value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && + !value.Contains('^') && + !value.Any(character => char.IsControl(character) || character == '\uFFFD'); + + private static string NetRowId(int index) => $"manual-net-row-{index + 1}"; + + private static bool TryParseNetRowId(string rowId, out int index) + { + index = Array.FindIndex( + Enumerable.Range(0, NetSellRowCount).Select(NetRowId).ToArray(), + value => string.Equals(value, rowId, StringComparison.Ordinal)); + return index >= 0; + } + + private int FindViItem(string itemId) => _viItems.FindIndex(item => + string.Equals(item.ItemId, itemId, StringComparison.Ordinal)); + + private string NextOpaqueId(string prefix) => $"{prefix}-{++_opaqueSequence:D8}"; + + private void SetStatus(string message, LegacyOperatorStatusKind kind) + { + _statusMessage = message; + _statusKind = kind; + } + + private void QuarantineWrites() + { + _writesQuarantined = true; + _netRowsAreFresh = false; + _viItemsAreFresh = false; + SetStatus(OutcomeUnknownMessage, LegacyOperatorStatusKind.Error); + } + + private static string ImportFailureMessage(string code) => code switch + { + "SOURCE_UNAVAILABLE" => "원본 수동 데이터 파일을 찾을 수 없습니다.", + "SOURCE_INVALID" => "원본 수동 데이터 파일 형식이 올바르지 않습니다.", + "ALREADY_IMPORTED" => "원본 수동 데이터는 이미 한 번 가져왔습니다.", + "DESTINATION_NOT_EMPTY" => "현재 저장소에 파일이 있어 원본 데이터를 덮어쓰지 않았습니다.", + "INTERRUPTED_IMPORT" => "이전 가져오기 작업이 중단된 상태입니다. 파일을 먼저 확인하세요.", + _ => "원본 수동 데이터 가져오기를 완료하지 못했습니다." + }; + + private sealed record ViDraftItem(string ItemId, LegacyManualViStoredItem Stored); + + private sealed record ViSearchResult(string ResultId, LegacyStockIdentity Identity); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs new file mode 100644 index 0000000..cde4bc1 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/NamedPlaylist/LegacyNamedPlaylistWorkflow.cs @@ -0,0 +1,1346 @@ +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyNamedPlaylistReadStatus +{ + Idle, + Loading, + Ready, + Error +} + +public enum LegacyNamedPlaylistFreshness +{ + None, + Optimistic, + Fresh +} + +public enum LegacyNamedPlaylistMutationOperation +{ + Create, + SaveCurrent, + Delete +} + +public enum LegacyNamedPlaylistMutationOutcome +{ + CommittedFresh, + CommittedOptimistic, + RolledBack, + NotFound, + Busy, + ReadOnly, + OutcomeUnknown, + Quarantined +} + +public sealed record LegacyNamedPlaylistDefinitionView( + string DefinitionId, + string Title, + bool IsSelected); + +public sealed record LegacyNamedPlaylistSelectionView( + string GroupCode, + string Subject, + string GraphicType, + string Subtype, + string DataCode); + +public sealed record LegacyNamedPlaylistPageView( + int CurrentPage, + int TotalPages, + string PageText, + bool IsWithinPlayoutBounds); + +public sealed record LegacyNamedPlaylistRowView( + string RowId, + int ItemIndex, + bool IsEnabled, + LegacyNamedPlaylistSelectionView Selection, + LegacyNamedPlaylistPageView? Page); + +/// +/// A title-only create request minted by one workflow instance. The internal eight-digit +/// DC_LIST identity is deliberately absent and cannot be supplied by Web callers. +/// +public sealed class LegacyNamedPlaylistProgramDraft +{ + internal LegacyNamedPlaylistProgramDraft( + object authority, + string draftId, + string title) + { + Authority = authority; + DraftId = draftId; + Title = title; + } + + public string DraftId { get; } + + public string Title { get; } + + internal object Authority { get; } +} + +/// +/// One trusted native playlist row projected into the original seven-field shape without +/// exposing a caret-delimited LIST_TEXT value. Array order becomes the contiguous item index. +/// +public sealed class LegacyNamedPlaylistRowDraft +{ + internal LegacyNamedPlaylistRowDraft( + object authority, + string draftId, + bool isEnabled, + LegacySceneSelection selection, + NamedPlaylistPageState? page) + { + Authority = authority; + DraftId = draftId; + IsEnabled = isEnabled; + Selection = LegacyNamedPlaylistWorkflowController.ToView(selection); + Page = LegacyNamedPlaylistWorkflowController.ToView(page); + StoredSelection = selection; + StoredPage = page; + } + + public string DraftId { get; } + + public bool IsEnabled { get; } + + public LegacyNamedPlaylistSelectionView Selection { get; } + + public LegacyNamedPlaylistPageView? Page { get; } + + internal object Authority { get; } + + internal LegacySceneSelection StoredSelection { get; } + + internal NamedPlaylistPageState? StoredPage { get; } +} + +public sealed class LegacyNamedPlaylistWorkflowSnapshot +{ + internal LegacyNamedPlaylistWorkflowSnapshot( + long revision, + LegacyNamedPlaylistReadStatus readStatus, + IEnumerable definitions, + bool isListTruncated, + string? selectedDefinitionId, + string? selectedTitle, + IEnumerable rows, + LegacyNamedPlaylistFreshness listFreshness, + LegacyNamedPlaylistFreshness documentFreshness, + bool isWriteInProgress, + bool isWriteQuarantined, + bool persistenceCanMutate, + bool hasSuggestedIdentity, + LegacyNamedPlaylistMutationOutcome? lastMutationOutcome, + string statusMessage, + LegacyOperatorStatusKind statusKind) + { + Revision = revision; + ReadStatus = readStatus; + Definitions = Array.AsReadOnly(definitions.ToArray()); + IsListTruncated = isListTruncated; + SelectedDefinitionId = selectedDefinitionId; + SelectedTitle = selectedTitle; + Rows = Array.AsReadOnly(rows.ToArray()); + ListFreshness = listFreshness; + DocumentFreshness = documentFreshness; + IsWriteInProgress = isWriteInProgress; + IsWriteQuarantined = isWriteQuarantined; + CanMutate = persistenceCanMutate && !isWriteQuarantined; + CanCreate = CanMutate && !isWriteInProgress && hasSuggestedIdentity; + CanLoad = selectedDefinitionId is not null && !isWriteInProgress; + CanSave = CanMutate && CanLoad; + CanDelete = CanMutate && CanLoad; + LastMutationOutcome = lastMutationOutcome; + StatusMessage = statusMessage; + StatusKind = statusKind; + } + + public long Revision { get; } + + public LegacyNamedPlaylistReadStatus ReadStatus { get; } + + public IReadOnlyList Definitions { get; } + + public bool IsListTruncated { get; } + + public string? SelectedDefinitionId { get; } + + public string? SelectedTitle { get; } + + public IReadOnlyList Rows { get; } + + public LegacyNamedPlaylistFreshness ListFreshness { get; } + + public LegacyNamedPlaylistFreshness DocumentFreshness { get; } + + public bool IsWriteInProgress { get; } + + public bool IsWriteQuarantined { get; } + + public bool CanMutate { get; } + + public bool CanCreate { get; } + + public bool CanLoad { get; } + + public bool CanSave { get; } + + public bool CanDelete { get; } + + public LegacyNamedPlaylistMutationOutcome? LastMutationOutcome { get; } + + public string StatusMessage { get; } + + public LegacyOperatorStatusKind StatusKind { get; } +} + +public sealed class LegacyNamedPlaylistMutationResult +{ + internal LegacyNamedPlaylistMutationResult( + string mutationId, + LegacyNamedPlaylistMutationOperation operation, + LegacyNamedPlaylistMutationOutcome outcome, + LegacyNamedPlaylistWorkflowSnapshot snapshot) + { + MutationId = mutationId; + Operation = operation; + Outcome = outcome; + Snapshot = snapshot; + } + + public string MutationId { get; } + + public LegacyNamedPlaylistMutationOperation Operation { get; } + + public LegacyNamedPlaylistMutationOutcome Outcome { get; } + + public bool AutomaticRetryAllowed => false; + + public bool IsCommitted => Outcome is + LegacyNamedPlaylistMutationOutcome.CommittedFresh or + LegacyNamedPlaylistMutationOutcome.CommittedOptimistic; + + public LegacyNamedPlaylistWorkflowSnapshot Snapshot { get; } +} + +/// +/// C# authority for the original modal PList/AList workflow. The injected +/// is the only read/write seam. A write is +/// attempted at most once, all rows are sent in one replace call, and an ambiguous result +/// permanently quarantines subsequent writes for this workflow instance. +/// +public sealed class LegacyNamedPlaylistWorkflowController +{ + public const int DefaultMaximumDefinitions = + LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions; + + public const int MaximumDefinitions = + LegacyNamedPlaylistPersistenceService.MaximumDefinitions; + + public const int MaximumItems = LegacyNamedPlaylistPersistenceService.MaximumItems; + + private const int MaximumTitleLength = 128; + private const int MaximumSelectionFieldLength = 256; + private const int MaximumSubjectFieldLength = 4_000; + private const int MaximumListTextLength = 4_000; + private const string QuarantineMessage = + "A named-playlist write outcome is unknown. Restart before another write."; + + private readonly object _gate = new(); + private readonly object _authority = new(); + private readonly SemaphoreSlim _writeGate = new(1, 1); + private readonly INamedPlaylistPersistenceService _persistence; + private List _definitions = []; + private List _rows = []; + private string? _selectedProgramCode; + private string? _selectedDefinitionId; + private string? _selectedTitle; + private string? _suggestedProgramCode; + private string _statusMessage = string.Empty; + private LegacyNamedPlaylistReadStatus _readStatus; + private LegacyNamedPlaylistFreshness _listFreshness; + private LegacyNamedPlaylistFreshness _documentFreshness; + private LegacyNamedPlaylistMutationOutcome? _lastMutationOutcome; + private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; + private bool _isListTruncated; + private bool _isWriteInProgress; + private bool _isWriteQuarantined; + private int _listGeneration; + private int _loadGeneration; + private long _mutationEpoch; + private long _opaqueSequence; + private long _revision; + + public LegacyNamedPlaylistWorkflowController( + INamedPlaylistPersistenceService persistence) + { + _persistence = persistence ?? throw new ArgumentNullException(nameof(persistence)); + } + + public LegacyNamedPlaylistWorkflowSnapshot Current + { + get + { + lock (_gate) + { + return CreateSnapshot(); + } + } + } + + public async Task RefreshAsync( + int maximumResults = DefaultMaximumDefinitions, + CancellationToken cancellationToken = default) + { + if (maximumResults is < 1 or > MaximumDefinitions) + { + throw new ArgumentOutOfRangeException(nameof(maximumResults)); + } + + cancellationToken.ThrowIfCancellationRequested(); + int generation; + long mutationEpoch; + lock (_gate) + { + RequireNoWriteInProgress(); + generation = ++_listGeneration; + mutationEpoch = _mutationEpoch; + _readStatus = LegacyNamedPlaylistReadStatus.Loading; + _statusMessage = "이름 있는 편성표 목록을 조회하고 있습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + } + + try + { + var result = await _persistence.ListAsync( + maximumResults, + cancellationToken).ConfigureAwait(false); + var suggested = await _persistence.SuggestNextProgramCodeAsync( + cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + var definitions = ValidateList(result, maximumResults); + ValidateProgramCode(suggested); + + lock (_gate) + { + if (generation != _listGeneration || mutationEpoch != _mutationEpoch) + { + return CreateSnapshot(); + } + + ApplyDefinitionList( + definitions, + result.IsTruncated, + suggested, + LegacyNamedPlaylistFreshness.Fresh); + _readStatus = LegacyNamedPlaylistReadStatus.Ready; + _statusMessage = $"이름 있는 편성표 {_definitions.Count}개를 조회했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + } + catch (OperationCanceledException) + { + lock (_gate) + { + if (generation == _listGeneration && mutationEpoch == _mutationEpoch) + { + _readStatus = LegacyNamedPlaylistReadStatus.Idle; + _statusMessage = "이름 있는 편성표 목록 조회가 취소되었습니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + } + } + + throw; + } + catch + { + lock (_gate) + { + if (generation == _listGeneration && mutationEpoch == _mutationEpoch) + { + _readStatus = LegacyNamedPlaylistReadStatus.Error; + _statusMessage = "이름 있는 편성표 목록을 안전하게 조회하지 못했습니다."; + _statusKind = LegacyOperatorStatusKind.Error; + Touch(); + } + } + + throw; + } + } + + public LegacyNamedPlaylistWorkflowSnapshot SelectDefinition(string definitionId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(definitionId); + lock (_gate) + { + RequireNoWriteInProgress(); + var selected = _definitions.FirstOrDefault(definition => + string.Equals(definition.DefinitionId, definitionId, StringComparison.Ordinal)) ?? + throw new KeyNotFoundException("The named-playlist definition id is stale or unknown."); + _selectedProgramCode = selected.Summary.ProgramCode; + _selectedDefinitionId = selected.DefinitionId; + _selectedTitle = selected.Summary.Title; + _rows = []; + _documentFreshness = LegacyNamedPlaylistFreshness.None; + ++_loadGeneration; + _statusMessage = $"'{selected.Summary.Title}' 편성표를 선택했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + } + + public async Task LoadSelectedAsync( + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + string programCode; + int generation; + long mutationEpoch; + lock (_gate) + { + RequireNoWriteInProgress(); + programCode = _selectedProgramCode ?? + throw new InvalidOperationException("Select a named playlist first."); + generation = ++_loadGeneration; + mutationEpoch = _mutationEpoch; + _readStatus = LegacyNamedPlaylistReadStatus.Loading; + _statusMessage = "선택한 편성표를 불러오고 있습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + } + + try + { + var document = ValidateDocument( + await _persistence.LoadAsync(programCode, cancellationToken).ConfigureAwait(false), + programCode); + cancellationToken.ThrowIfCancellationRequested(); + lock (_gate) + { + if (generation != _loadGeneration || mutationEpoch != _mutationEpoch || + !string.Equals( + programCode, + _selectedProgramCode, + StringComparison.Ordinal)) + { + return CreateSnapshot(); + } + + ApplyDocument(document, LegacyNamedPlaylistFreshness.Fresh); + _readStatus = LegacyNamedPlaylistReadStatus.Ready; + _statusMessage = $"'{document.Definition.Title}' 편성표를 불러왔습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + } + catch (OperationCanceledException) + { + lock (_gate) + { + if (generation == _loadGeneration && mutationEpoch == _mutationEpoch) + { + _readStatus = LegacyNamedPlaylistReadStatus.Idle; + _statusMessage = "선택한 편성표 불러오기가 취소되었습니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + Touch(); + } + } + + throw; + } + catch + { + lock (_gate) + { + if (generation == _loadGeneration && mutationEpoch == _mutationEpoch) + { + _readStatus = LegacyNamedPlaylistReadStatus.Error; + _statusMessage = "선택한 편성표를 안전하게 불러오지 못했습니다."; + _statusKind = LegacyOperatorStatusKind.Error; + Touch(); + } + } + + throw; + } + } + + public LegacyNamedPlaylistProgramDraft CreateProgramDraft(string title) + { + var canonicalTitle = ValidateCanonicalText( + title, + MaximumTitleLength, + allowEmpty: false, + allowCaret: true, + nameof(title)); + lock (_gate) + { + return new LegacyNamedPlaylistProgramDraft( + _authority, + NextOpaqueId("named-program-draft"), + canonicalTitle); + } + } + + public LegacyNamedPlaylistRowDraft CreateRowDraft( + LegacyPlaylistEntry entry, + NamedPlaylistPageState? page) + { + ArgumentNullException.ThrowIfNull(entry); + var selection = entry.Selection ?? + throw new ArgumentException("A storable playlist entry requires a selection.", nameof(entry)); + var validated = ValidateStoredItem( + new NamedPlaylistStoredItem(0, entry.IsEnabled, selection, page), + 0, + nameof(entry)); + lock (_gate) + { + return CreateRowDraftLocked(validated); + } + } + + public LegacyNamedPlaylistRowDraft CreateDraftFromLoadedRow(string rowId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rowId); + lock (_gate) + { + var row = _rows.FirstOrDefault(candidate => + string.Equals(candidate.RowId, rowId, StringComparison.Ordinal)) ?? + throw new KeyNotFoundException("The named-playlist row id is stale or unknown."); + return CreateRowDraftLocked(row.Item); + } + } + + public Task CreateProgramAsync( + LegacyNamedPlaylistProgramDraft draft, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(draft); + string programCode; + lock (_gate) + { + RequireOwnedDraft(draft.Authority, nameof(draft)); + programCode = _suggestedProgramCode ?? + throw new InvalidOperationException( + "Refresh the named-playlist list before creating a program."); + } + + return ExecuteMutationAsync( + LegacyNamedPlaylistMutationOperation.Create, + token => _persistence.CreateDefinitionAsync(programCode, draft.Title, token), + () => ApplyOptimisticCreate(programCode, draft.Title), + token => ConfirmCreatedAsync(programCode, draft.Title, token), + cancellationToken); + } + + public Task SaveCurrentAsync( + IReadOnlyList rows, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(rows); + if (rows.Count > MaximumItems) + { + throw new ArgumentOutOfRangeException(nameof(rows)); + } + + string programCode; + NamedPlaylistStoredItem[] items; + lock (_gate) + { + programCode = _selectedProgramCode ?? + throw new InvalidOperationException("Select a named playlist first."); + items = new NamedPlaylistStoredItem[rows.Count]; + for (var index = 0; index < rows.Count; index++) + { + var draft = rows[index] ?? + throw new ArgumentException("A named-playlist row draft is missing.", nameof(rows)); + RequireOwnedDraft(draft.Authority, nameof(rows)); + items[index] = ValidateStoredItem( + new NamedPlaylistStoredItem( + index, + draft.IsEnabled, + draft.StoredSelection, + draft.StoredPage), + index, + nameof(rows)); + } + } + + var immutableItems = Array.AsReadOnly(items); + return ExecuteMutationAsync( + LegacyNamedPlaylistMutationOperation.SaveCurrent, + token => _persistence.ReplaceItemsAsync(programCode, immutableItems, token), + () => ApplyOptimisticRows(items), + token => ConfirmSavedAsync(programCode, items, token), + cancellationToken); + } + + public Task DeleteSelectedAsync( + CancellationToken cancellationToken = default) + { + string programCode; + lock (_gate) + { + programCode = _selectedProgramCode ?? + throw new InvalidOperationException("Select a named playlist first."); + } + + return ExecuteMutationAsync( + LegacyNamedPlaylistMutationOperation.Delete, + token => _persistence.DeleteAsync(programCode, token), + () => ApplyOptimisticDelete(programCode), + token => ConfirmDeletedAsync(programCode, token), + cancellationToken); + } + + private async Task ExecuteMutationAsync( + LegacyNamedPlaylistMutationOperation operation, + Func mutation, + Action applyOptimistic, + Func> confirmFresh, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + string mutationId; + lock (_gate) + { + mutationId = NextOpaqueId("named-mutation"); + if (!_persistence.CanMutate) + { + return CompleteWithoutAttempt( + mutationId, + operation, + LegacyNamedPlaylistMutationOutcome.ReadOnly, + "이름 있는 편성표 저장소가 읽기 전용입니다."); + } + + if (_isWriteQuarantined) + { + return CompleteWithoutAttempt( + mutationId, + operation, + LegacyNamedPlaylistMutationOutcome.Quarantined, + QuarantineMessage); + } + } + + if (!await _writeGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return new LegacyNamedPlaylistMutationResult( + mutationId, + operation, + LegacyNamedPlaylistMutationOutcome.Busy, + Current); + } + + var began = false; + var outcome = LegacyNamedPlaylistMutationOutcome.OutcomeUnknown; + try + { + lock (_gate) + { + if (_isWriteQuarantined) + { + outcome = LegacyNamedPlaylistMutationOutcome.Quarantined; + _lastMutationOutcome = outcome; + _statusMessage = QuarantineMessage; + _statusKind = LegacyOperatorStatusKind.Error; + Touch(); + } + else + { + cancellationToken.ThrowIfCancellationRequested(); + _isWriteInProgress = true; + _statusMessage = "이름 있는 편성표 변경을 한 번만 실행하고 있습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + began = true; + Touch(); + } + } + + if (began) + { + var mutationCommitted = false; + try + { + // From this point onward cancellation or an unclassified exception is + // ambiguous. The workflow never invokes this delegate more than once. + await mutation(cancellationToken).ConfigureAwait(false); + mutationCommitted = true; + } + catch (NamedPlaylistMutationException exception) + { + outcome = exception.OutcomeUnknown + ? LegacyNamedPlaylistMutationOutcome.OutcomeUnknown + : LegacyNamedPlaylistMutationOutcome.RolledBack; + RecordMutationFailure(outcome); + } + catch (NamedPlaylistNotFoundException) + { + outcome = LegacyNamedPlaylistMutationOutcome.NotFound; + RecordMutationFailure(outcome); + } + catch (ArgumentException) + { + outcome = LegacyNamedPlaylistMutationOutcome.RolledBack; + RecordMutationFailure(outcome); + } + catch + { + outcome = LegacyNamedPlaylistMutationOutcome.OutcomeUnknown; + RecordMutationFailure(outcome); + } + + if (mutationCommitted) + { + lock (_gate) + { + applyOptimistic(); + ++_mutationEpoch; + ++_listGeneration; + ++_loadGeneration; + _statusMessage = "변경은 커밋되었으며 DB 재조회로 확인하고 있습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + } + + var isFresh = false; + try + { + isFresh = await confirmFresh(cancellationToken).ConfigureAwait(false); + } + catch + { + // The mutation already returned a known committed result. A failed read-back + // keeps the optimistic copy and never causes a second mutation attempt. + isFresh = false; + } + + outcome = isFresh + ? LegacyNamedPlaylistMutationOutcome.CommittedFresh + : LegacyNamedPlaylistMutationOutcome.CommittedOptimistic; + lock (_gate) + { + _lastMutationOutcome = outcome; + _readStatus = LegacyNamedPlaylistReadStatus.Ready; + _statusMessage = isFresh + ? "이름 있는 편성표 변경을 DB 재조회로 확인했습니다." + : "변경은 커밋되었지만 재조회에 실패해 optimistic 상태로 표시합니다."; + _statusKind = isFresh + ? LegacyOperatorStatusKind.Information + : LegacyOperatorStatusKind.Warning; + Touch(); + } + } + } + } + finally + { + if (began) + { + lock (_gate) + { + _isWriteInProgress = false; + Touch(); + } + } + + _writeGate.Release(); + } + + return FinishMutation(mutationId, operation, outcome); + } + + private LegacyNamedPlaylistMutationResult FinishMutation( + string mutationId, + LegacyNamedPlaylistMutationOperation operation, + LegacyNamedPlaylistMutationOutcome outcome) => + new(mutationId, operation, outcome, Current); + + private LegacyNamedPlaylistMutationResult CompleteWithoutAttempt( + string mutationId, + LegacyNamedPlaylistMutationOperation operation, + LegacyNamedPlaylistMutationOutcome outcome, + string message) + { + _lastMutationOutcome = outcome; + _statusMessage = message; + _statusKind = outcome == LegacyNamedPlaylistMutationOutcome.ReadOnly + ? LegacyOperatorStatusKind.Warning + : LegacyOperatorStatusKind.Error; + Touch(); + return new LegacyNamedPlaylistMutationResult( + mutationId, + operation, + outcome, + CreateSnapshot()); + } + + private void RecordMutationFailure(LegacyNamedPlaylistMutationOutcome outcome) + { + lock (_gate) + { + _lastMutationOutcome = outcome; + switch (outcome) + { + case LegacyNamedPlaylistMutationOutcome.OutcomeUnknown: + _isWriteQuarantined = true; + _statusMessage = QuarantineMessage; + _statusKind = LegacyOperatorStatusKind.Error; + break; + + case LegacyNamedPlaylistMutationOutcome.NotFound: + _statusMessage = "선택한 이름 있는 편성표가 더 이상 존재하지 않습니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + break; + + default: + _statusMessage = "변경은 롤백되었으며 자동 재시도하지 않았습니다."; + _statusKind = LegacyOperatorStatusKind.Warning; + break; + } + + Touch(); + } + } + + private async Task ConfirmCreatedAsync( + string programCode, + string expectedTitle, + CancellationToken cancellationToken) + { + var document = ValidateDocument( + await _persistence.LoadAsync(programCode, cancellationToken).ConfigureAwait(false), + programCode); + if (!string.Equals(document.Definition.Title, expectedTitle, StringComparison.Ordinal) || + document.Items.Count != 0) + { + return false; + } + + var list = await _persistence.ListAsync( + MaximumDefinitions, + cancellationToken).ConfigureAwait(false); + var definitions = ValidateList(list, MaximumDefinitions); + var suggested = await _persistence.SuggestNextProgramCodeAsync( + cancellationToken).ConfigureAwait(false); + ValidateProgramCode(suggested); + if (!definitions.Any(definition => + string.Equals(definition.ProgramCode, programCode, StringComparison.Ordinal) && + string.Equals(definition.Title, expectedTitle, StringComparison.Ordinal))) + { + return false; + } + + lock (_gate) + { + ApplyDefinitionList( + definitions, + list.IsTruncated, + suggested, + LegacyNamedPlaylistFreshness.Fresh, + programCode); + ApplyDocument(document, LegacyNamedPlaylistFreshness.Fresh); + Touch(); + } + + return true; + } + + private async Task ConfirmSavedAsync( + string programCode, + IReadOnlyList expectedItems, + CancellationToken cancellationToken) + { + var document = ValidateDocument( + await _persistence.LoadAsync(programCode, cancellationToken).ConfigureAwait(false), + programCode); + if (!document.Items.SequenceEqual(expectedItems)) + { + return false; + } + + lock (_gate) + { + if (!string.Equals(programCode, _selectedProgramCode, StringComparison.Ordinal)) + { + return false; + } + + ApplyDocument(document, LegacyNamedPlaylistFreshness.Fresh); + Touch(); + } + + return true; + } + + private async Task ConfirmDeletedAsync( + string deletedProgramCode, + CancellationToken cancellationToken) + { + var list = await _persistence.ListAsync( + MaximumDefinitions, + cancellationToken).ConfigureAwait(false); + var definitions = ValidateList(list, MaximumDefinitions); + var suggested = await _persistence.SuggestNextProgramCodeAsync( + cancellationToken).ConfigureAwait(false); + ValidateProgramCode(suggested); + if (definitions.Any(definition => string.Equals( + definition.ProgramCode, + deletedProgramCode, + StringComparison.Ordinal))) + { + return false; + } + + lock (_gate) + { + ApplyDefinitionList( + definitions, + list.IsTruncated, + suggested, + LegacyNamedPlaylistFreshness.Fresh); + _rows = []; + _documentFreshness = LegacyNamedPlaylistFreshness.None; + Touch(); + } + + return true; + } + + private void ApplyOptimisticCreate(string programCode, string title) + { + var definition = new DefinitionState( + NextOpaqueId("named-definition"), + new NamedPlaylistSummary(programCode, title)); + _definitions.Add(definition); + _definitions = _definitions + .OrderBy(item => item.Summary.Title, StringComparer.Ordinal) + .ThenBy(item => item.Summary.ProgramCode, StringComparer.Ordinal) + .ToList(); + _selectedProgramCode = programCode; + _selectedDefinitionId = definition.DefinitionId; + _selectedTitle = title; + _rows = []; + _suggestedProgramCode = null; + _listFreshness = LegacyNamedPlaylistFreshness.Optimistic; + _documentFreshness = LegacyNamedPlaylistFreshness.Optimistic; + } + + private void ApplyOptimisticRows(IReadOnlyList items) + { + _rows = CreateRowStates(items); + _documentFreshness = LegacyNamedPlaylistFreshness.Optimistic; + } + + private void ApplyOptimisticDelete(string programCode) + { + _definitions.RemoveAll(definition => string.Equals( + definition.Summary.ProgramCode, + programCode, + StringComparison.Ordinal)); + _selectedProgramCode = null; + _selectedDefinitionId = null; + _selectedTitle = null; + _rows = []; + _suggestedProgramCode = null; + _listFreshness = LegacyNamedPlaylistFreshness.Optimistic; + _documentFreshness = LegacyNamedPlaylistFreshness.None; + } + + private void ApplyDefinitionList( + IReadOnlyList summaries, + bool isTruncated, + string suggestedProgramCode, + LegacyNamedPlaylistFreshness freshness, + string? preferredSelectionCode = null) + { + var selectedCode = preferredSelectionCode ?? _selectedProgramCode; + _definitions = summaries.Select(summary => new DefinitionState( + NextOpaqueId("named-definition"), + summary)).ToList(); + _isListTruncated = isTruncated; + _suggestedProgramCode = suggestedProgramCode; + _listFreshness = freshness; + + var selected = selectedCode is null + ? null + : _definitions.FirstOrDefault(definition => string.Equals( + definition.Summary.ProgramCode, + selectedCode, + StringComparison.Ordinal)); + if (selected is null) + { + _selectedProgramCode = null; + _selectedDefinitionId = null; + _selectedTitle = null; + _rows = []; + _documentFreshness = LegacyNamedPlaylistFreshness.None; + ++_loadGeneration; + return; + } + + _selectedProgramCode = selected.Summary.ProgramCode; + _selectedDefinitionId = selected.DefinitionId; + _selectedTitle = selected.Summary.Title; + } + + private void ApplyDocument( + NamedPlaylistDocument document, + LegacyNamedPlaylistFreshness freshness) + { + var definitionIndex = _definitions.FindIndex(definition => string.Equals( + definition.Summary.ProgramCode, + document.Definition.ProgramCode, + StringComparison.Ordinal)); + if (definitionIndex >= 0) + { + var existing = _definitions[definitionIndex]; + _definitions[definitionIndex] = existing with { Summary = document.Definition }; + _selectedDefinitionId = existing.DefinitionId; + } + + _selectedProgramCode = document.Definition.ProgramCode; + _selectedTitle = document.Definition.Title; + _rows = CreateRowStates(document.Items); + _documentFreshness = freshness; + } + + private List CreateRowStates(IEnumerable items) => + items.Select(item => new RowState( + NextOpaqueId("named-row"), + item)).ToList(); + + private LegacyNamedPlaylistRowDraft CreateRowDraftLocked(NamedPlaylistStoredItem item) => + new( + _authority, + NextOpaqueId("named-row-draft"), + item.IsEnabled, + item.Selection, + item.Page); + + private LegacyNamedPlaylistWorkflowSnapshot CreateSnapshot() + { + var definitions = _definitions.Select(definition => + new LegacyNamedPlaylistDefinitionView( + definition.DefinitionId, + definition.Summary.Title, + string.Equals( + definition.DefinitionId, + _selectedDefinitionId, + StringComparison.Ordinal))).ToArray(); + var rows = _rows.Select(row => new LegacyNamedPlaylistRowView( + row.RowId, + row.Item.ItemIndex, + row.Item.IsEnabled, + ToView(row.Item.Selection), + ToView(row.Item.Page))).ToArray(); + return new LegacyNamedPlaylistWorkflowSnapshot( + _revision, + _readStatus, + definitions, + _isListTruncated, + _selectedDefinitionId, + _selectedTitle, + rows, + _listFreshness, + _documentFreshness, + _isWriteInProgress, + _isWriteQuarantined, + _persistence.CanMutate, + _suggestedProgramCode is not null, + _lastMutationOutcome, + _statusMessage, + _statusKind); + } + + internal static LegacyNamedPlaylistSelectionView ToView(LegacySceneSelection selection) => + new( + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + selection.DataCode); + + internal static LegacyNamedPlaylistPageView? ToView(NamedPlaylistPageState? page) => + page is null + ? null + : new LegacyNamedPlaylistPageView( + page.CurrentPage, + page.TotalPages, + page.ToString(), + page.IsWithinPlayoutBounds); + + private static IReadOnlyList ValidateList( + NamedPlaylistListResult result, + int maximumResults) + { + ArgumentNullException.ThrowIfNull(result); + if (result.Items is null || result.Items.Count > maximumResults || + result.IsTruncated && result.Items.Count != maximumResults) + { + throw new NamedPlaylistDataException("The named-playlist list envelope is invalid."); + } + + var codes = new HashSet(StringComparer.Ordinal); + var items = new NamedPlaylistSummary[result.Items.Count]; + for (var index = 0; index < result.Items.Count; index++) + { + var item = result.Items[index] ?? + throw new NamedPlaylistDataException("A named-playlist definition is missing."); + ValidateProgramCode(item.ProgramCode); + try + { + ValidateCanonicalText( + item.Title, + MaximumTitleLength, + allowEmpty: false, + allowCaret: true, + nameof(result)); + } + catch (ArgumentException exception) + { + throw new NamedPlaylistDataException( + $"A named-playlist definition title is invalid: {exception.Message}"); + } + + if (!codes.Add(item.ProgramCode)) + { + throw new NamedPlaylistDataException( + "A named-playlist definition identity is duplicated."); + } + + items[index] = item; + } + + return Array.AsReadOnly(items); + } + + private static NamedPlaylistDocument ValidateDocument( + NamedPlaylistDocument document, + string expectedProgramCode) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(document.Definition); + ValidateProgramCode(document.Definition.ProgramCode); + if (!string.Equals( + document.Definition.ProgramCode, + expectedProgramCode, + StringComparison.Ordinal)) + { + throw new NamedPlaylistDataException( + "The named-playlist document identity does not match the request."); + } + + string title; + try + { + title = ValidateCanonicalText( + document.Definition.Title, + MaximumTitleLength, + allowEmpty: false, + allowCaret: true, + nameof(document)); + } + catch (ArgumentException exception) + { + throw new NamedPlaylistDataException( + $"A named-playlist document title is invalid: {exception.Message}"); + } + + if (document.Items is null || document.Items.Count > MaximumItems) + { + throw new NamedPlaylistDataException("The named-playlist row count is invalid."); + } + + var items = new NamedPlaylistStoredItem[document.Items.Count]; + for (var index = 0; index < document.Items.Count; index++) + { + try + { + items[index] = ValidateStoredItem(document.Items[index], index, nameof(document)); + } + catch (ArgumentException exception) + { + throw new NamedPlaylistDataException( + $"A named-playlist document row is invalid: {exception.Message}"); + } + } + + return new NamedPlaylistDocument( + new NamedPlaylistSummary(expectedProgramCode, title), + document.RetrievedAt, + Array.AsReadOnly(items)); + } + + private static NamedPlaylistStoredItem ValidateStoredItem( + NamedPlaylistStoredItem? item, + int expectedIndex, + string parameterName) + { + if (item is null || item.ItemIndex != expectedIndex || item.Selection is null) + { + throw new ArgumentException( + "Named-playlist rows require contiguous zero-based indexes.", + parameterName); + } + + var selection = new LegacySceneSelection( + ValidateCanonicalText( + item.Selection.GroupCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateCanonicalText( + item.Selection.Subject, + MaximumSubjectFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateCanonicalText( + item.Selection.GraphicType, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateCanonicalText( + item.Selection.Subtype, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName), + ValidateCanonicalText( + item.Selection.DataCode, + MaximumSelectionFieldLength, + allowEmpty: true, + allowCaret: false, + parameterName)); + var validated = new NamedPlaylistStoredItem( + expectedIndex, + item.IsEnabled, + selection, + item.Page); + var serializedLength = string.Join( + '^', + validated.IsEnabled ? "1" : "0", + selection.GroupCode, + selection.Subject, + selection.GraphicType, + selection.Subtype, + validated.Page?.ToString() ?? string.Empty, + selection.DataCode).Length; + if (serializedLength > MaximumListTextLength) + { + throw new ArgumentException( + "A named-playlist seven-field row is too long.", + parameterName); + } + + return validated; + } + + private static string ValidateCanonicalText( + string value, + int maximumLength, + bool allowEmpty, + bool allowCaret, + string parameterName) + { + ArgumentNullException.ThrowIfNull(value); + if ((!allowEmpty && value.Length == 0) || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value) || !allowCaret && value.Contains('^')) + { + throw new ArgumentException("A named-playlist text value is invalid.", parameterName); + } + + return value; + } + + private static void ValidateProgramCode(string value) + { + if (value is null || value.Length != 8 || + value.Any(static character => !char.IsAsciiDigit(character))) + { + throw new NamedPlaylistDataException( + "A named-playlist internal identity must contain eight digits."); + } + } + + private static bool IsSafeText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private void RequireOwnedDraft(object authority, string parameterName) + { + if (!ReferenceEquals(authority, _authority)) + { + throw new ArgumentException( + "The named-playlist draft belongs to another workflow instance.", + parameterName); + } + } + + private void RequireNoWriteInProgress() + { + if (_isWriteInProgress) + { + throw new InvalidOperationException( + "The named-playlist state cannot change while a write is in progress."); + } + } + + private string NextOpaqueId(string prefix) => + $"{prefix}-{EncodeOpaqueSequence(++_opaqueSequence)}"; + + private static string EncodeOpaqueSequence(long value) + { + Span encoded = stackalloc char[12]; + for (var index = encoded.Length - 1; index >= 0; index--) + { + encoded[index] = (char)('A' + value % 26); + value /= 26; + } + + return new string(encoded); + } + + private void Touch() => _revision++; + + private sealed record DefinitionState( + string DefinitionId, + NamedPlaylistSummary Summary); + + private sealed record RowState( + string RowId, + NamedPlaylistStoredItem Item); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs new file mode 100644 index 0000000..bf47908 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/OperatorCatalog/LegacyOperatorCatalogWorkflow.cs @@ -0,0 +1,1388 @@ +using System.Collections.ObjectModel; +using System.Globalization; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyOperatorCatalogEntity +{ + Theme, + Expert +} + +public enum LegacyOperatorCatalogMode +{ + List, + Create, + Edit +} + +public enum LegacyOperatorCatalogStatusKind +{ + Neutral, + Information, + Warning, + Error +} + +public enum LegacyOperatorCatalogMoveDirection +{ + Up, + Down +} + +public sealed record LegacyOperatorCatalogResultView( + string ResultId, + string CodeLabel, + string DisplayName, + string Scope, + bool IsSelected); + +public sealed record LegacyOperatorCatalogDraftRowView( + string RowId, + string DisplayName, + string Market, + decimal? BuyAmount, + int Position); + +public sealed record LegacyOperatorCatalogStockResultView( + string ResultId, + string DisplayName, + string Market, + bool IsCompatible, + bool IsSelected); + +public sealed record LegacyOperatorCatalogWorkflowSnapshot( + long Revision, + bool IsOpen, + LegacyOperatorCatalogEntity Entity, + LegacyOperatorCatalogMode Mode, + string Query, + ThemeSession NxtSession, + ThemeMarket NewThemeMarket, + IReadOnlyList Results, + bool ResultsAreTruncated, + string? SelectedResultId, + string CodeLabel, + string EditorName, + IReadOnlyList DraftRows, + string StockQuery, + IReadOnlyList StockResults, + bool StockResultsAreTruncated, + string? SelectedStockResultId, + bool SchemaValidated, + bool WritesQuarantined, + bool CanBeginCreate, + bool CanSave, + bool CanDelete, + bool CanAddStock, + string StatusMessage, + LegacyOperatorCatalogStatusKind StatusKind); + +internal sealed class LegacyOperatorCatalogWriteSafetyState +{ + private const string DefaultQuarantineMessage = + "ThemeA/EList database write outcome is unknown. Restart the application and reconcile the database before another write."; + + private readonly object _sync = new(); + private long _correlationGeneration; + private bool _writeInFlight; + private bool _writesQuarantined; + private string? _quarantineReason; + + internal SemaphoreSlim WriteGate { get; } = new(1, 1); + + internal bool IsQuarantined(out string? reason) + { + lock (_sync) + { + reason = _quarantineReason; + return _writesQuarantined; + } + } + + internal bool TryBegin(out long generation, out string reason) + { + lock (_sync) + { + generation = _correlationGeneration; + if (_writesQuarantined) + { + reason = _quarantineReason ?? DefaultQuarantineMessage; + return false; + } + + if (_writeInFlight) + { + reason = "Another ThemeA/EList write is already running."; + return false; + } + + reason = string.Empty; + return true; + } + } + + internal bool MarkDispatched(long generation) + { + lock (_sync) + { + if (_writesQuarantined || generation != _correlationGeneration || _writeInFlight) + { + return false; + } + + _writeInFlight = true; + return true; + } + } + + internal bool IsCurrent(long generation) + { + lock (_sync) + { + return !_writesQuarantined && generation == _correlationGeneration; + } + } + + internal void Complete() + { + lock (_sync) + { + _writeInFlight = false; + } + } + + internal string Quarantine(string? reason = null) + { + lock (_sync) + { + _writesQuarantined = true; + _writeInFlight = false; + _quarantineReason ??= string.IsNullOrWhiteSpace(reason) + ? DefaultQuarantineMessage + : reason; + return _quarantineReason; + } + } + + internal void InvalidateCorrelation() + { + lock (_sync) + { + _correlationGeneration = checked(_correlationGeneration + 1); + if (_writeInFlight) + { + _writesQuarantined = true; + _quarantineReason ??= DefaultQuarantineMessage; + _writeInFlight = false; + } + } + } +} + +/// +/// C#-authoritative migration of ThemeA and EList. Web callers can select only opaque +/// catalog, stock, and draft-row ids. Database identities, provider routing, item codes, +/// optimistic identities, and mutation ordering remain inside this workflow. Each explicit +/// save/delete intent dispatches at most one transaction. An uncertain result latches the +/// process-lifetime write quarantine and is never retried. +/// +public sealed class LegacyOperatorCatalogWorkflowController +{ + public const int MaximumCatalogResults = 500; + public const int MaximumStockResults = 100; + + private const decimal MaximumWebSafeInteger = 9_007_199_254_740_991m; + private const string OutcomeUnknownMessage = + "ThemeA/EList 저장 결과를 확인할 수 없어 현재 프로세스의 추가 DB 쓰기를 차단했습니다. DB를 대조한 뒤 프로그램을 다시 시작하세요."; + + private static readonly LegacyOperatorCatalogWriteSafetyState ProcessWriteSafety = new(); + + private readonly IThemeSelectionService _themeSelectionService; + private readonly IExpertSelectionService _expertSelectionService; + private readonly IThemeCatalogPersistenceService _themePersistenceService; + private readonly IExpertCatalogPersistenceService _expertPersistenceService; + private readonly IStockSearchService _stockSearchService; + private readonly IStockMasterIdentityValidationService _identityValidationService; + private readonly IOperatorCatalogSchemaValidationService _schemaValidationService; + private readonly LegacyOperatorCatalogWriteSafetyState _writeSafety; + + private readonly List _results = []; + private readonly List _draftRows = []; + private readonly List _stockResults = []; + private long _revision; + private bool _isOpen; + private bool _schemaValidated; + private bool _resultsAreTruncated; + private bool _stockResultsAreTruncated; + private LegacyOperatorCatalogEntity _entity = LegacyOperatorCatalogEntity.Theme; + private LegacyOperatorCatalogMode _mode = LegacyOperatorCatalogMode.List; + private ThemeSession _nxtSession = ThemeSession.PreMarket; + private ThemeMarket _newThemeMarket = ThemeMarket.Krx; + private string _query = string.Empty; + private string _stockQuery = string.Empty; + private string _codeLabel = string.Empty; + private string _editorName = string.Empty; + private string? _selectedResultId; + private string? _selectedStockResultId; + private ThemeSelectionIdentity? _expectedThemeIdentity; + private ExpertSelectionIdentity? _expectedExpertIdentity; + private string _statusMessage = string.Empty; + private LegacyOperatorCatalogStatusKind _statusKind = LegacyOperatorCatalogStatusKind.Neutral; + + public LegacyOperatorCatalogWorkflowController( + IThemeSelectionService themeSelectionService, + IExpertSelectionService expertSelectionService, + IThemeCatalogPersistenceService themePersistenceService, + IExpertCatalogPersistenceService expertPersistenceService, + IStockSearchService stockSearchService, + IStockMasterIdentityValidationService identityValidationService, + IOperatorCatalogSchemaValidationService schemaValidationService) + : this( + themeSelectionService, + expertSelectionService, + themePersistenceService, + expertPersistenceService, + stockSearchService, + identityValidationService, + schemaValidationService, + ProcessWriteSafety) + { + } + + internal LegacyOperatorCatalogWorkflowController( + IThemeSelectionService themeSelectionService, + IExpertSelectionService expertSelectionService, + IThemeCatalogPersistenceService themePersistenceService, + IExpertCatalogPersistenceService expertPersistenceService, + IStockSearchService stockSearchService, + IStockMasterIdentityValidationService identityValidationService, + IOperatorCatalogSchemaValidationService schemaValidationService, + LegacyOperatorCatalogWriteSafetyState writeSafety) + { + _themeSelectionService = themeSelectionService ?? + throw new ArgumentNullException(nameof(themeSelectionService)); + _expertSelectionService = expertSelectionService ?? + throw new ArgumentNullException(nameof(expertSelectionService)); + _themePersistenceService = themePersistenceService ?? + throw new ArgumentNullException(nameof(themePersistenceService)); + _expertPersistenceService = expertPersistenceService ?? + throw new ArgumentNullException(nameof(expertPersistenceService)); + _stockSearchService = stockSearchService ?? + throw new ArgumentNullException(nameof(stockSearchService)); + _identityValidationService = identityValidationService ?? + throw new ArgumentNullException(nameof(identityValidationService)); + _schemaValidationService = schemaValidationService ?? + throw new ArgumentNullException(nameof(schemaValidationService)); + _writeSafety = writeSafety ?? throw new ArgumentNullException(nameof(writeSafety)); + } + + public LegacyOperatorCatalogWorkflowSnapshot Current => CreateSnapshot(); + + public async Task OpenAsync( + LegacyOperatorCatalogEntity entity, + CancellationToken cancellationToken = default) + { + _isOpen = true; + _entity = entity; + _mode = LegacyOperatorCatalogMode.List; + _query = string.Empty; + ClearEditor(); + ClearStatus(); + + try + { + if (!_schemaValidated) + { + var validation = await _schemaValidationService.ValidateAsync(cancellationToken) + .ConfigureAwait(false); + _schemaValidated = validation.ValidatedSources.Contains(DataSourceKind.Oracle) && + validation.ValidatedSources.Contains(DataSourceKind.MariaDb); + if (!_schemaValidated) + { + throw new OperatorCatalogSchemaException( + DataSourceKind.Oracle, + "The complete ThemeA/EList schema was not validated."); + } + } + + await LoadCatalogListCoreAsync(cancellationToken).ConfigureAwait(false); + SetStatus( + entity == LegacyOperatorCatalogEntity.Theme + ? "ThemeA 목록을 불러왔습니다." + : "EList 목록을 불러왔습니다.", + LegacyOperatorCatalogStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + _schemaValidated = false; + _results.Clear(); + _resultsAreTruncated = false; + SetStatus( + "ThemeA/EList 스키마 또는 목록을 확인하지 못했습니다. DB 연결을 확인하세요.", + LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + /// + /// Opens the ThemeA editor for the exact identity already selected by UC4. + /// The browser never receives or sends this database identity; it remains + /// correlated inside the native workflows from selection through mutation. + /// + public async Task OpenThemeForEditAsync( + ThemeSelectionIdentity identity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + await OpenAsync(LegacyOperatorCatalogEntity.Theme, cancellationToken) + .ConfigureAwait(false); + + var session = identity.Market == ThemeMarket.Nxt + ? identity.Session + : ThemeSession.PreMarket; + await SearchAsync(identity.ThemeTitle, session, cancellationToken) + .ConfigureAwait(false); + + var selected = _results.SingleOrDefault(row => row.ThemeIdentity == identity); + if (selected is null) + { + ClearEditor(); + SetStatus( + "선택한 테마의 현재 DB identity를 다시 확인하지 못해 편집을 시작하지 않았습니다.", + LegacyOperatorCatalogStatusKind.Error); + Touch(); + return CreateSnapshot(); + } + + return await SelectAsync(selected.ResultId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Opens the EList editor for the exact identity already selected by UC6. + /// + public async Task OpenExpertForEditAsync( + ExpertSelectionIdentity identity, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(identity); + await OpenAsync(LegacyOperatorCatalogEntity.Expert, cancellationToken) + .ConfigureAwait(false); + await SearchAsync(identity.ExpertName, ThemeSession.PreMarket, cancellationToken) + .ConfigureAwait(false); + + var selected = _results.SingleOrDefault(row => row.ExpertIdentity == identity); + if (selected is null) + { + ClearEditor(); + SetStatus( + "선택한 전문가의 현재 DB identity를 다시 확인하지 못해 편집을 시작하지 않았습니다.", + LegacyOperatorCatalogStatusKind.Error); + Touch(); + return CreateSnapshot(); + } + + return await SelectAsync(selected.ResultId, cancellationToken).ConfigureAwait(false); + } + + public LegacyOperatorCatalogWorkflowSnapshot Close() + { + _isOpen = false; + _mode = LegacyOperatorCatalogMode.List; + ClearEditor(); + Touch(); + return CreateSnapshot(); + } + + public async Task SearchAsync( + string query, + ThemeSession nxtSession, + CancellationToken cancellationToken = default) + { + EnsureOpen(); + _query = ValidateQuery(query); + if (nxtSession is not (ThemeSession.PreMarket or ThemeSession.AfterMarket)) + { + throw new ArgumentOutOfRangeException(nameof(nxtSession)); + } + + _nxtSession = nxtSession; + _mode = LegacyOperatorCatalogMode.List; + ClearEditor(); + try + { + await LoadCatalogListCoreAsync(cancellationToken).ConfigureAwait(false); + SetStatus("목록 검색을 완료했습니다.", LegacyOperatorCatalogStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + _results.Clear(); + _resultsAreTruncated = false; + SetStatus("목록을 검색하지 못했습니다.", LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + public async Task SelectAsync( + string resultId, + CancellationToken cancellationToken = default) + { + EnsureOpen(); + var selected = FindResult(resultId); + try + { + _draftRows.Clear(); + if (selected.ThemeIdentity is { } themeIdentity) + { + var preview = await _themeSelectionService.PreviewAsync( + themeIdentity, + LegacyThemeSelectionService.MaximumPreviewItems, + cancellationToken).ConfigureAwait(false); + if (preview.IsTruncated || preview.Identity != themeIdentity) + { + throw new ThemeSelectionDataException( + "A complete, correlated ThemeA preview is required for editing."); + } + + foreach (var item in preview.Items.OrderBy(static item => item.InputIndex)) + { + _draftRows.Add(DraftRowState.ForTheme( + NewOpaqueId("catalog-draft"), + item.ItemCode, + item.ItemName)); + } + + _expectedThemeIdentity = themeIdentity; + _expectedExpertIdentity = null; + _codeLabel = themeIdentity.ThemeCode; + _editorName = themeIdentity.ThemeTitle; + _newThemeMarket = themeIdentity.Market; + } + else if (selected.ExpertIdentity is { } expertIdentity) + { + var preview = await _expertSelectionService.PreviewAsync( + expertIdentity, + LegacyExpertSelectionService.MaximumPreviewItems, + cancellationToken).ConfigureAwait(false); + if (preview.IsTruncated || preview.Identity != expertIdentity) + { + throw new ExpertSelectionDataException( + "A complete, correlated EList preview is required for editing."); + } + + foreach (var item in preview.Items.OrderBy(static item => item.PlayIndex)) + { + _draftRows.Add(DraftRowState.ForExpert( + NewOpaqueId("catalog-draft"), + item.StockCode, + item.StockName, + item.BuyAmount)); + } + + _expectedThemeIdentity = null; + _expectedExpertIdentity = expertIdentity; + _codeLabel = expertIdentity.ExpertCode; + _editorName = expertIdentity.ExpertName; + } + else + { + throw new InvalidOperationException("The catalog result has no trusted identity."); + } + + _selectedResultId = resultId; + _mode = LegacyOperatorCatalogMode.Edit; + ResetStockSearch(); + SetStatus("편집할 항목을 불러왔습니다.", LegacyOperatorCatalogStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + ClearEditor(); + SetStatus( + "전체 구성 데이터를 다시 읽지 못해 편집을 시작하지 않았습니다.", + LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + public async Task BeginCreateThemeAsync( + ThemeMarket market, + CancellationToken cancellationToken = default) + { + EnsureOpenEntity(LegacyOperatorCatalogEntity.Theme); + if (!CanMutateCurrentEntity()) + { + return RejectUnavailableWrite(); + } + + try + { + _codeLabel = await _themePersistenceService.SuggestNextCodeAsync( + market, + cancellationToken).ConfigureAwait(false); + _newThemeMarket = market; + _mode = LegacyOperatorCatalogMode.Create; + _editorName = string.Empty; + _selectedResultId = null; + _expectedThemeIdentity = null; + _expectedExpertIdentity = null; + _draftRows.Clear(); + ResetStockSearch(); + SetStatus("새 ThemeA 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + ClearEditor(); + SetStatus("새 ThemeA 코드를 만들지 못했습니다.", LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + public async Task BeginCreateExpertAsync( + CancellationToken cancellationToken = default) + { + EnsureOpenEntity(LegacyOperatorCatalogEntity.Expert); + if (!CanMutateCurrentEntity()) + { + return RejectUnavailableWrite(); + } + + try + { + _codeLabel = await _expertPersistenceService.SuggestNextCodeAsync(cancellationToken) + .ConfigureAwait(false); + _mode = LegacyOperatorCatalogMode.Create; + _editorName = string.Empty; + _selectedResultId = null; + _expectedThemeIdentity = null; + _expectedExpertIdentity = null; + _draftRows.Clear(); + ResetStockSearch(); + SetStatus("새 EList 항목을 입력하세요.", LegacyOperatorCatalogStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + ClearEditor(); + SetStatus("새 EList 코드를 만들지 못했습니다.", LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + public async Task SearchStocksAsync( + string query, + CancellationToken cancellationToken = default) + { + EnsureEditing(); + _stockQuery = ValidateRequiredText(query, LegacyStockSearchService.MaximumQueryLength); + _selectedStockResultId = null; + try + { + var result = await _stockSearchService.SearchAsync( + _stockQuery, + MaximumStockResults, + cancellationToken).ConfigureAwait(false); + _stockResults.Clear(); + _stockResults.AddRange(result.Items.Select(item => new StockResultState( + NewOpaqueId("catalog-stock"), + item))); + _stockResultsAreTruncated = result.IsTruncated; + SetStatus("종목 검색을 완료했습니다.", LegacyOperatorCatalogStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch + { + _stockResults.Clear(); + _stockResultsAreTruncated = false; + SetStatus("종목 master를 검색하지 못했습니다.", LegacyOperatorCatalogStatusKind.Error); + } + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorCatalogWorkflowSnapshot SelectStock(string resultId) + { + EnsureEditing(); + var stock = FindStock(resultId); + if (!IsCompatible(stock.Item)) + { + SetStatus("현재 편집 시장과 일치하지 않는 종목입니다.", LegacyOperatorCatalogStatusKind.Warning); + } + else + { + _selectedStockResultId = resultId; + SetStatus("추가할 종목을 선택했습니다.", LegacyOperatorCatalogStatusKind.Information); + } + + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorCatalogWorkflowSnapshot AddSelectedStock(decimal? buyAmount) + { + EnsureEditing(); + var selected = _selectedStockResultId is null + ? throw new InvalidOperationException("Select a stock-master result first.") + : FindStock(_selectedStockResultId); + if (!IsCompatible(selected.Item)) + { + throw new InvalidOperationException("The selected stock is incompatible with this catalog."); + } + + DraftRowState row; + if (_entity == LegacyOperatorCatalogEntity.Theme) + { + if (_draftRows.Count >= LegacyThemeCatalogPersistenceService.MaximumItems) + { + throw new InvalidOperationException("ThemeA item limit reached."); + } + + var itemCode = ThemeItemCode(selected.Item); + var itemName = ThemeStoredName(selected.Item); + row = DraftRowState.ForTheme( + NewOpaqueId("catalog-draft"), + itemCode, + itemName); + } + else + { + if (_draftRows.Count >= LegacyExpertCatalogPersistenceService.MaximumRecommendations) + { + throw new InvalidOperationException("EList recommendation limit reached."); + } + + if (buyAmount is null || decimal.Truncate(buyAmount.Value) != buyAmount.Value || + buyAmount.Value <= 0 || buyAmount.Value > MaximumWebSafeInteger) + { + throw new ArgumentOutOfRangeException(nameof(buyAmount)); + } + + row = DraftRowState.ForExpert( + NewOpaqueId("catalog-draft"), + selected.Item.Code, + selected.Item.Name, + buyAmount.Value); + } + + if (_draftRows.Any(existing => + string.Equals(existing.Code, row.Code, StringComparison.Ordinal) || + string.Equals(existing.Name, row.Name, StringComparison.Ordinal))) + { + SetStatus("같은 코드 또는 이름의 종목이 이미 있습니다.", LegacyOperatorCatalogStatusKind.Warning); + Touch(); + return CreateSnapshot(); + } + + _draftRows.Add(row); + _selectedStockResultId = null; + SetStatus("종목을 편집 목록에 추가했습니다.", LegacyOperatorCatalogStatusKind.Information); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorCatalogWorkflowSnapshot MoveDraftRow( + string rowId, + LegacyOperatorCatalogMoveDirection direction) + { + EnsureEditing(); + var index = FindDraftIndex(rowId); + var target = direction == LegacyOperatorCatalogMoveDirection.Up ? index - 1 : index + 1; + if (target >= 0 && target < _draftRows.Count) + { + (_draftRows[index], _draftRows[target]) = (_draftRows[target], _draftRows[index]); + SetStatus("표시 순서를 변경했습니다.", LegacyOperatorCatalogStatusKind.Information); + Touch(); + } + + return CreateSnapshot(); + } + + public LegacyOperatorCatalogWorkflowSnapshot SetDraftBuyAmount( + string rowId, + decimal buyAmount) + { + EnsureEditing(); + if (_entity != LegacyOperatorCatalogEntity.Expert) + { + throw new InvalidOperationException( + "Only EList recommendation rows have a buy amount."); + } + + if (decimal.Truncate(buyAmount) != buyAmount || + buyAmount <= 0 || buyAmount > MaximumWebSafeInteger) + { + throw new ArgumentOutOfRangeException(nameof(buyAmount)); + } + + var index = FindDraftIndex(rowId); + _draftRows[index] = _draftRows[index] with { BuyAmount = buyAmount }; + SetStatus("추천 종목의 매수가를 변경했습니다. 저장을 눌러 DB에 반영하세요.", + LegacyOperatorCatalogStatusKind.Information); + Touch(); + return CreateSnapshot(); + } + + public LegacyOperatorCatalogWorkflowSnapshot RemoveDraftRow(string rowId) + { + EnsureEditing(); + _draftRows.RemoveAt(FindDraftIndex(rowId)); + SetStatus("편집 목록에서 종목을 삭제했습니다.", LegacyOperatorCatalogStatusKind.Information); + Touch(); + return CreateSnapshot(); + } + + public Task SaveAsync( + string editorName, + CancellationToken cancellationToken = default) + { + EnsureEditing(); + _editorName = ValidateRequiredText(editorName, 128); + + if (_entity == LegacyOperatorCatalogEntity.Theme) + { + var items = _draftRows.Select((row, index) => + new ThemeCatalogItem(index, row.Code, row.Name)).ToArray(); + if (_mode == LegacyOperatorCatalogMode.Create) + { + var definition = new ThemeCatalogDefinition( + _newThemeMarket, + _codeLabel, + _editorName, + Array.AsReadOnly(items)); + return ExecuteWriteAsync( + "ThemeA를 저장했습니다.", + token => _identityValidationService.ValidateThemeItemsAsync(items, token), + token => _themePersistenceService.CreateAsync(definition, token), + cancellationToken); + } + + var expected = _expectedThemeIdentity ?? throw new InvalidOperationException( + "The trusted ThemeA edit identity is missing."); + return ExecuteWriteAsync( + "ThemeA를 저장했습니다.", + token => _identityValidationService.ValidateThemeItemsAsync(items, token), + token => _themePersistenceService.ReplaceAsync( + expected, + _editorName, + items, + token), + cancellationToken); + } + + var recommendations = _draftRows.Select((row, index) => + new ExpertCatalogRecommendation( + index, + row.Code, + row.Name, + row.BuyAmount ?? throw new InvalidOperationException( + "An EList recommendation amount is missing."))).ToArray(); + if (_mode == LegacyOperatorCatalogMode.Create) + { + var definition = new ExpertCatalogDefinition( + new ExpertSelectionIdentity(_codeLabel, _editorName), + Array.AsReadOnly(recommendations)); + return ExecuteWriteAsync( + "EList를 저장했습니다.", + token => _identityValidationService.ValidateExpertRecommendationsAsync( + recommendations, + token), + token => _expertPersistenceService.CreateAsync(definition, token), + cancellationToken); + } + + var expectedExpert = _expectedExpertIdentity ?? throw new InvalidOperationException( + "The trusted EList edit identity is missing."); + return ExecuteWriteAsync( + "EList를 저장했습니다.", + token => _identityValidationService.ValidateExpertRecommendationsAsync( + recommendations, + token), + token => _expertPersistenceService.ReplaceAsync( + expectedExpert, + _editorName, + recommendations, + token), + cancellationToken); + } + + public Task DeleteAsync( + CancellationToken cancellationToken = default) + { + EnsureEditing(); + if (_mode != LegacyOperatorCatalogMode.Edit) + { + throw new InvalidOperationException("Only a freshly loaded catalog row can be deleted."); + } + + if (_entity == LegacyOperatorCatalogEntity.Theme) + { + var expected = _expectedThemeIdentity ?? throw new InvalidOperationException( + "The trusted ThemeA delete identity is missing."); + return ExecuteWriteAsync( + "ThemeA를 삭제했습니다.", + validation: null, + token => _themePersistenceService.DeleteAsync(expected, token), + cancellationToken); + } + + var expectedExpert = _expectedExpertIdentity ?? throw new InvalidOperationException( + "The trusted EList delete identity is missing."); + return ExecuteWriteAsync( + "EList를 삭제했습니다.", + validation: null, + token => _expertPersistenceService.DeleteAsync(expectedExpert, token), + cancellationToken); + } + + public LegacyOperatorCatalogWorkflowSnapshot InvalidateBrowserCorrelation() + { + _writeSafety.InvalidateCorrelation(); + if (_writeSafety.IsQuarantined(out var reason)) + { + SetStatus(reason ?? OutcomeUnknownMessage, LegacyOperatorCatalogStatusKind.Error); + Touch(); + } + + return CreateSnapshot(); + } + + private async Task ExecuteWriteAsync( + string successMessage, + Func? validation, + Func> mutation, + CancellationToken cancellationToken) + { + if (!CanMutateCurrentEntity()) + { + return RejectUnavailableWrite(); + } + + if (!await _writeSafety.WriteGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + SetStatus("다른 ThemeA/EList 쓰기가 진행 중입니다.", LegacyOperatorCatalogStatusKind.Warning); + Touch(); + return CreateSnapshot(); + } + + var dispatched = false; + try + { + if (!_writeSafety.TryBegin(out var generation, out var reason)) + { + SetStatus(reason, LegacyOperatorCatalogStatusKind.Error); + Touch(); + return CreateSnapshot(); + } + + if (validation is not null) + { + await validation(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + } + + if (!_writeSafety.IsCurrent(generation) || !_writeSafety.MarkDispatched(generation)) + { + SetStatus( + "브라우저 상관관계가 변경되어 DB transaction을 시작하지 않았습니다.", + LegacyOperatorCatalogStatusKind.Warning); + Touch(); + return CreateSnapshot(); + } + + dispatched = true; + var receipt = await mutation(cancellationToken).ConfigureAwait(false); + if (receipt.OperationId == Guid.Empty || !_writeSafety.IsCurrent(generation)) + { + throw new OperatorCatalogMutationException( + OutcomeUnknownMessage, + outcomeUnknown: true); + } + + _writeSafety.Complete(); + dispatched = false; + ClearEditor(); + try + { + await LoadCatalogListCoreAsync(cancellationToken).ConfigureAwait(false); + SetStatus(successMessage, LegacyOperatorCatalogStatusKind.Information); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + SetStatus( + $"{successMessage} 목록 새로고침은 취소되었습니다.", + LegacyOperatorCatalogStatusKind.Warning); + } + catch + { + SetStatus( + $"{successMessage} 다만 목록을 새로 읽지 못했으므로 다시 검색하세요.", + LegacyOperatorCatalogStatusKind.Warning); + } + } + catch (OperatorCatalogConflictException) + { + _writeSafety.Complete(); + dispatched = false; + SetStatus( + "DB 내용이 바뀌었거나 코드/이름이 중복되어 transaction이 rollback되었습니다. 목록을 다시 읽으세요.", + LegacyOperatorCatalogStatusKind.Warning); + } + catch (StockMasterIdentityDataException) when (!dispatched) + { + _writeSafety.Complete(); + dispatched = false; + SetStatus( + "모든 종목은 현재 stock master의 유일한 코드·이름과 일치해야 합니다.", + LegacyOperatorCatalogStatusKind.Error); + } + catch (OperatorCatalogMutationException exception) + { + if (exception.OutcomeUnknown) + { + var quarantine = _writeSafety.Quarantine(OutcomeUnknownMessage); + SetStatus(quarantine, LegacyOperatorCatalogStatusKind.Error); + } + else + { + _writeSafety.Complete(); + SetStatus( + "DB transaction이 rollback되었으며 자동으로 재시도하지 않았습니다.", + LegacyOperatorCatalogStatusKind.Error); + } + + dispatched = false; + } + catch (OperationCanceledException) when (dispatched) + { + dispatched = false; + SetStatus( + _writeSafety.Quarantine(OutcomeUnknownMessage), + LegacyOperatorCatalogStatusKind.Error); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _writeSafety.Complete(); + throw; + } + catch + { + if (dispatched) + { + SetStatus( + _writeSafety.Quarantine(OutcomeUnknownMessage), + LegacyOperatorCatalogStatusKind.Error); + } + else + { + _writeSafety.Complete(); + SetStatus( + "DB transaction 시작 전에 요청이 실패했으며 자동으로 재시도하지 않았습니다.", + LegacyOperatorCatalogStatusKind.Error); + } + + dispatched = false; + } + finally + { + if (dispatched) + { + _writeSafety.Quarantine(OutcomeUnknownMessage); + } + + _writeSafety.WriteGate.Release(); + Touch(); + } + + return CreateSnapshot(); + } + + private async Task LoadCatalogListCoreAsync(CancellationToken cancellationToken) + { + _results.Clear(); + _selectedResultId = null; + if (_entity == LegacyOperatorCatalogEntity.Theme) + { + var result = await _themeSelectionService.SearchAsync( + _query, + _nxtSession, + MaximumCatalogResults, + cancellationToken).ConfigureAwait(false); + _query = result.Query; + _resultsAreTruncated = result.IsTruncated; + _results.AddRange(result.Items.Select(item => CatalogResultState.ForTheme( + NewOpaqueId("catalog-result"), + item.Identity, + item.Source))); + } + else + { + var result = await _expertSelectionService.SearchAsync( + _query, + MaximumCatalogResults, + cancellationToken).ConfigureAwait(false); + _query = result.Query; + _resultsAreTruncated = result.IsTruncated; + _results.AddRange(result.Items.Select(item => CatalogResultState.ForExpert( + NewOpaqueId("catalog-result"), + item.Identity, + item.Source))); + } + } + + private LegacyOperatorCatalogWorkflowSnapshot CreateSnapshot() + { + var quarantined = _writeSafety.IsQuarantined(out var quarantineReason); + var canMutate = CanMutateCurrentEntity() && !quarantined; + var resultViews = _results.Select(row => new LegacyOperatorCatalogResultView( + row.ResultId, + row.CodeLabel, + row.DisplayName, + row.Scope, + string.Equals(row.ResultId, _selectedResultId, StringComparison.Ordinal))).ToArray(); + var draftViews = _draftRows.Select((row, index) => + new LegacyOperatorCatalogDraftRowView( + row.RowId, + row.Name, + row.MarketLabel, + row.BuyAmount, + index)).ToArray(); + var stockViews = _stockResults.Select(row => + new LegacyOperatorCatalogStockResultView( + row.ResultId, + row.Item.Name, + StockMarketId(row.Item.Market), + IsCompatible(row.Item), + string.Equals( + row.ResultId, + _selectedStockResultId, + StringComparison.Ordinal))).ToArray(); + + return new LegacyOperatorCatalogWorkflowSnapshot( + _revision, + _isOpen, + _entity, + _mode, + _query, + _nxtSession, + _newThemeMarket, + Array.AsReadOnly(resultViews), + _resultsAreTruncated, + _selectedResultId, + _codeLabel, + _editorName, + Array.AsReadOnly(draftViews), + _stockQuery, + Array.AsReadOnly(stockViews), + _stockResultsAreTruncated, + _selectedStockResultId, + _schemaValidated, + quarantined, + canMutate && _mode == LegacyOperatorCatalogMode.List, + canMutate && + (_mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit), + canMutate && _mode == LegacyOperatorCatalogMode.Edit, + canMutate && + (_mode is LegacyOperatorCatalogMode.Create or LegacyOperatorCatalogMode.Edit) && + _selectedStockResultId is not null, + quarantined && string.IsNullOrWhiteSpace(_statusMessage) + ? quarantineReason ?? OutcomeUnknownMessage + : _statusMessage, + quarantined ? LegacyOperatorCatalogStatusKind.Error : _statusKind); + } + + private bool CanMutateCurrentEntity() => + _schemaValidated && !_writeSafety.IsQuarantined(out _) && + (_entity == LegacyOperatorCatalogEntity.Theme + ? _themePersistenceService.CanMutate + : _expertPersistenceService.CanMutate); + + private LegacyOperatorCatalogWorkflowSnapshot RejectUnavailableWrite() + { + SetStatus( + _writeSafety.IsQuarantined(out var reason) + ? reason ?? OutcomeUnknownMessage + : "ThemeA/EList DB 쓰기를 사용할 수 없습니다.", + LegacyOperatorCatalogStatusKind.Error); + Touch(); + return CreateSnapshot(); + } + + private void ClearEditor() + { + _mode = LegacyOperatorCatalogMode.List; + _selectedResultId = null; + _expectedThemeIdentity = null; + _expectedExpertIdentity = null; + _codeLabel = string.Empty; + _editorName = string.Empty; + _draftRows.Clear(); + ResetStockSearch(); + } + + private void ResetStockSearch() + { + _stockQuery = string.Empty; + _stockResults.Clear(); + _stockResultsAreTruncated = false; + _selectedStockResultId = null; + } + + private void EnsureOpen() + { + if (!_isOpen) + { + throw new InvalidOperationException("The ThemeA/EList workspace is not open."); + } + } + + private void EnsureOpenEntity(LegacyOperatorCatalogEntity entity) + { + EnsureOpen(); + if (_entity != entity) + { + throw new InvalidOperationException("The requested catalog entity is not active."); + } + } + + private void EnsureEditing() + { + EnsureOpen(); + if (_mode == LegacyOperatorCatalogMode.List) + { + throw new InvalidOperationException("Start a new or existing catalog edit first."); + } + } + + private CatalogResultState FindResult(string resultId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resultId); + return _results.SingleOrDefault(row => + string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) ?? + throw new ArgumentException("The opaque catalog result id is invalid.", nameof(resultId)); + } + + private StockResultState FindStock(string resultId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(resultId); + return _stockResults.SingleOrDefault(row => + string.Equals(row.ResultId, resultId, StringComparison.Ordinal)) ?? + throw new ArgumentException("The opaque stock result id is invalid.", nameof(resultId)); + } + + private int FindDraftIndex(string rowId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rowId); + var index = _draftRows.FindIndex(row => + string.Equals(row.RowId, rowId, StringComparison.Ordinal)); + return index >= 0 + ? index + : throw new ArgumentException("The opaque draft row id is invalid.", nameof(rowId)); + } + + private bool IsCompatible(StockSearchItem item) => _entity switch + { + LegacyOperatorCatalogEntity.Expert => true, + LegacyOperatorCatalogEntity.Theme when _newThemeMarket == ThemeMarket.Krx => + item.Market is StockMarket.Kospi or StockMarket.Kosdaq, + LegacyOperatorCatalogEntity.Theme => + item.Market is StockMarket.NxtKospi or StockMarket.NxtKosdaq, + _ => false + }; + + private static string ThemeItemCode(StockSearchItem item) => item.Market switch + { + StockMarket.Kospi => "P" + item.Code, + StockMarket.Kosdaq => "D" + item.Code, + StockMarket.NxtKospi => "X" + item.Code, + StockMarket.NxtKosdaq => "T" + item.Code, + _ => throw new ArgumentOutOfRangeException(nameof(item)) + }; + + private static string ThemeStoredName(StockSearchItem item) + { + if (item.Market is not (StockMarket.NxtKospi or StockMarket.NxtKosdaq)) + { + return item.Name; + } + + const string suffix = "(NXT)"; + if (!item.Name.EndsWith(suffix, StringComparison.Ordinal)) + { + throw new InvalidOperationException("An NXT stock display name is invalid."); + } + + return ValidateRequiredText(item.Name[..^suffix.Length], 200); + } + + private static string ValidateQuery(string value) + { + ArgumentNullException.ThrowIfNull(value); + var normalized = value.Trim(); + if (normalized.Length > 64 || !IsSafeText(normalized, allowEmpty: true)) + { + throw new ArgumentException("A safe catalog query is required.", nameof(value)); + } + + return normalized; + } + + private static string ValidateRequiredText(string value, int maximumLength) + { + ArgumentNullException.ThrowIfNull(value); + if (value.Length is < 1 || value.Length > maximumLength || + !string.Equals(value, value.Trim(), StringComparison.Ordinal) || + !IsSafeText(value, allowEmpty: false)) + { + throw new ArgumentException("A safe canonical text value is required.", nameof(value)); + } + + return value; + } + + private static bool IsSafeText(string value, bool allowEmpty) + { + if (!allowEmpty && value.Length == 0) + { + return false; + } + + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private void SetStatus(string message, LegacyOperatorCatalogStatusKind kind) + { + _statusMessage = message; + _statusKind = kind; + } + + private void ClearStatus() + { + _statusMessage = string.Empty; + _statusKind = LegacyOperatorCatalogStatusKind.Neutral; + } + + private void Touch() => _revision = checked(_revision + 1); + + private static string NewOpaqueId(string prefix) => + prefix + "-" + Guid.NewGuid().ToString("N"); + + private static string StockMarketId(StockMarket market) => market switch + { + StockMarket.Kospi => "KOSPI", + StockMarket.Kosdaq => "KOSDAQ", + StockMarket.NxtKospi => "NXT KOSPI", + StockMarket.NxtKosdaq => "NXT KOSDAQ", + _ => throw new ArgumentOutOfRangeException(nameof(market)) + }; + + private sealed record CatalogResultState( + string ResultId, + string CodeLabel, + string DisplayName, + string Scope, + ThemeSelectionIdentity? ThemeIdentity, + ExpertSelectionIdentity? ExpertIdentity) + { + internal static CatalogResultState ForTheme( + string resultId, + ThemeSelectionIdentity identity, + DataSourceKind source) => + new( + resultId, + identity.ThemeCode, + identity.ThemeTitle, + identity.Market == ThemeMarket.Krx ? "KRX / Oracle" : "NXT / MariaDB", + identity, + null); + + internal static CatalogResultState ForExpert( + string resultId, + ExpertSelectionIdentity identity, + DataSourceKind source) => + new( + resultId, + identity.ExpertCode, + identity.ExpertName, + source == DataSourceKind.Oracle ? "Oracle" : "MariaDB", + null, + identity); + } + + private sealed record DraftRowState( + string RowId, + string Code, + string Name, + string MarketLabel, + decimal? BuyAmount) + { + internal static DraftRowState ForTheme( + string rowId, + string code, + string name) => + new(rowId, code, name, ThemeMarketLabel(code), null); + + internal static DraftRowState ForExpert( + string rowId, + string code, + string name, + decimal buyAmount) => + new(rowId, code, name, "종목", buyAmount); + + private static string ThemeMarketLabel(string code) => code.Length > 0 + ? code[0] switch + { + 'P' => "KOSPI", + 'D' => "KOSDAQ", + 'X' => "NXT KOSPI", + 'T' => "NXT KOSDAQ", + _ => "종목" + } + : "종목"; + } + + private sealed record StockResultState(string ResultId, StockSearchItem Item); +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/Properties/AssemblyInfo.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..1837550 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.LegacyApplication.Tests")] diff --git a/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs new file mode 100644 index 0000000..bfac150 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyApplication/TradingHalt/LegacyTradingHaltWorkflow.cs @@ -0,0 +1,458 @@ +using System.Globalization; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.LegacyApplication; + +public enum LegacyTradingHaltRequestStatus +{ + Idle, + Loading, + Ready, + Error +} + +public enum LegacyTradingHaltNameSort +{ + None, + Descending, + Ascending +} + +public sealed record LegacyTradingHaltResultView( + string SelectionId, + string Market, + string StockCode, + string DisplayName, + bool IsSelected); + +public sealed record LegacyTradingHaltActionView( + string ActionId, + string Label, + bool IsAvailable); + +public sealed record LegacyTradingHaltWorkflowSnapshot( + long Revision, + string Query, + LegacyTradingHaltRequestStatus Status, + IReadOnlyList Results, + bool IsTruncated, + LegacyTradingHaltResultView? Selected, + LegacyTradingHaltNameSort NameSort, + bool Ma5, + bool Ma20, + IReadOnlyList Actions, + string ErrorMessage, + string StatusMessage, + LegacyOperatorStatusKind StatusKind); + +public sealed record LegacyTradingHaltPlaylistDraft( + string DraftId, + string BuilderKey, + string CutCode, + IReadOnlyList Aliases, + string Title, + string Detail, + string Category, + string Market, + string StockCode, + string DisplayName, + LegacySceneSelection Selection, + bool Ma5, + bool Ma20, + int? CandlePeriodDays, + string PageText, + bool IsEnabled, + int FadeDuration, + string Source, + string ActionId) +{ + public LegacyPlaylistEntry ToLegacyPlaylistEntry() => + new(DraftId, CutCode, IsEnabled, FadeDuration, Selection); +} + +/// +/// Native authority for UC7's combined KOSPI/KOSDAQ trading-halt selector. +/// The Web selects an opaque row and a closed action id; stock identity and scene fields +/// always come from the validated Core service result. +/// +public sealed class LegacyTradingHaltWorkflowController +{ + public const string CurrentActionId = "current"; + public const string CandleActionId = "candle-120d"; + public const int CandlePeriodDays = 120; + + private const int DefaultFadeDuration = 6; + private const string CurrentLabel = "거래정지-현재가"; + private const string CandleLabel = "캔들그래프-거래정지 · 120일"; + + private readonly object _gate = new(); + private readonly ITradingHaltSelectionService _service; + private List _results = []; + private HaltState? _selected; + private string _query = string.Empty; + private string _errorMessage = string.Empty; + private string _statusMessage = string.Empty; + private LegacyTradingHaltRequestStatus _status = LegacyTradingHaltRequestStatus.Idle; + private LegacyTradingHaltNameSort _nameSort; + private LegacyOperatorStatusKind _statusKind = LegacyOperatorStatusKind.Neutral; + private bool _isTruncated; + private bool _ma5; + private bool _ma20; + private int _searchGeneration; + private long _revision; + private long _draftSequence; + + public LegacyTradingHaltWorkflowController(ITradingHaltSelectionService service) + { + _service = service ?? throw new ArgumentNullException(nameof(service)); + } + + public LegacyTradingHaltWorkflowSnapshot Current + { + get + { + lock (_gate) + { + return CreateSnapshot(); + } + } + } + + public async Task SearchAsync( + string query, + CancellationToken cancellationToken = default) + { + var normalizedQuery = NormalizeQuery(query); + int generation; + lock (_gate) + { + generation = ++_searchGeneration; + _query = normalizedQuery; + _results = []; + _selected = null; + _nameSort = LegacyTradingHaltNameSort.None; + _status = LegacyTradingHaltRequestStatus.Loading; + _isTruncated = false; + _errorMessage = string.Empty; + Touch(); + } + + try + { + var result = await _service.SearchAsync( + normalizedQuery, + LegacyTradingHaltSelectionService.MaximumResults, + cancellationToken).ConfigureAwait(false); + lock (_gate) + { + if (generation != _searchGeneration) + { + return CreateSnapshot(); + } + + _results = ValidateResult(result, normalizedQuery, generation); + _isTruncated = result.IsTruncated; + _status = LegacyTradingHaltRequestStatus.Ready; + _errorMessage = string.Empty; + _statusMessage = $"KOSPI·KOSDAQ 거래정지 종목 {_results.Count}개를 조회했습니다."; + _statusKind = LegacyOperatorStatusKind.Information; + Touch(); + return CreateSnapshot(); + } + } + catch (OperationCanceledException) + { + lock (_gate) + { + if (generation == _searchGeneration) + { + _status = LegacyTradingHaltRequestStatus.Idle; + _errorMessage = string.Empty; + Touch(); + } + } + + throw; + } + catch + { + lock (_gate) + { + if (generation == _searchGeneration) + { + _results = []; + _selected = null; + _status = LegacyTradingHaltRequestStatus.Error; + _errorMessage = "거래정지 종목 조회 결과를 안전하게 확인하지 못했습니다."; + _statusMessage = _errorMessage; + _statusKind = LegacyOperatorStatusKind.Error; + Touch(); + } + } + + throw; + } + } + + public LegacyTradingHaltWorkflowSnapshot SelectResult(string selectionId) + { + ArgumentNullException.ThrowIfNull(selectionId); + lock (_gate) + { + _selected = _results.FirstOrDefault(item => + string.Equals(item.SelectionId, selectionId, StringComparison.Ordinal)) ?? + throw new ArgumentOutOfRangeException( + nameof(selectionId), + "The trading-halt selection id is not in the current result set."); + Touch(); + return CreateSnapshot(); + } + } + + public LegacyTradingHaltWorkflowSnapshot ToggleNameSort() + { + lock (_gate) + { + _nameSort = _nameSort switch + { + LegacyTradingHaltNameSort.Descending => LegacyTradingHaltNameSort.Ascending, + _ => LegacyTradingHaltNameSort.Descending + }; + Touch(); + return CreateSnapshot(); + } + } + + public LegacyTradingHaltWorkflowSnapshot SetMovingAverages(bool ma5, bool ma20) + { + lock (_gate) + { + _ma5 = ma5; + _ma20 = ma20; + Touch(); + return CreateSnapshot(); + } + } + + public LegacyTradingHaltPlaylistDraft MaterializeSelectedAction(string actionId) + { + ArgumentNullException.ThrowIfNull(actionId); + lock (_gate) + { + var selected = _selected ?? + throw new InvalidOperationException("Select a trading-halt stock first."); + var isCurrent = string.Equals(actionId, CurrentActionId, StringComparison.Ordinal); + var isCandle = string.Equals(actionId, CandleActionId, StringComparison.Ordinal); + if (!isCurrent && !isCandle) + { + throw new ArgumentOutOfRangeException(nameof(actionId)); + } + + var item = selected.Selection; + var market = item.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq"; + var builderKey = isCurrent ? "s5001" : "s8010"; + var cutCode = isCurrent ? "5001" : "8051"; + var graphicType = isCurrent + ? "1열판기본" + : string.Join( + ',', + new[] + { + _ma5 ? "MA5" : null, + _ma20 ? "MA20" : null + }.Where(static value => value is not null)); + var selection = new LegacySceneSelection( + isCurrent + ? item.Market == TradingHaltMarket.Kospi ? "KOSPI" : "KOSDAQ" + : item.Market == TradingHaltMarket.Kospi + ? "KOSPI_STOCK" + : "KOSDAQ_STOCK", + item.DisplayName, + graphicType, + isCurrent ? "HALTED" : "TRADING_HALT", + item.StockCode); + return new LegacyTradingHaltPlaylistDraft( + $"trading-halt-draft-{++_draftSequence:D8}", + builderKey, + cutCode, + Array.AsReadOnly(new[] { cutCode }), + item.DisplayName, + isCurrent ? CurrentLabel : CandleLabel, + isCurrent ? "stock" : "chart", + market, + item.StockCode, + item.DisplayName, + selection, + isCandle && _ma5, + isCandle && _ma20, + isCandle ? CandlePeriodDays : null, + "1/1", + true, + DefaultFadeDuration, + "legacy-trading-halt-workflow", + actionId); + } + } + + private LegacyTradingHaltWorkflowSnapshot CreateSnapshot() + { + var display = _results.ToArray(); + if (_nameSort != LegacyTradingHaltNameSort.None) + { + var multiplier = _nameSort == LegacyTradingHaltNameSort.Ascending ? 1 : -1; + display = display + .Select((item, sourceIndex) => (item, sourceIndex)) + .OrderBy(pair => pair.item.Selection.DisplayName, new DirectionComparer(multiplier)) + .ThenBy(pair => pair.item.Selection.Market) + .ThenBy(pair => pair.item.Selection.StockCode, StringComparer.Ordinal) + .ThenBy(pair => pair.sourceIndex) + .Select(pair => pair.item) + .ToArray(); + } + + var rows = display.Select(ToView).ToArray(); + var selected = _selected is null ? null : ToView(_selected); + var canAct = _selected is not null; + return new LegacyTradingHaltWorkflowSnapshot( + _revision, + _query, + _status, + Array.AsReadOnly(rows), + _isTruncated, + selected, + _nameSort, + _ma5, + _ma20, + Array.AsReadOnly(new[] + { + new LegacyTradingHaltActionView(CurrentActionId, CurrentLabel, canAct), + new LegacyTradingHaltActionView(CandleActionId, CandleLabel, canAct) + }), + _errorMessage, + _statusMessage, + _statusKind); + } + + private LegacyTradingHaltResultView ToView(HaltState state) => + new( + state.SelectionId, + state.Selection.Market == TradingHaltMarket.Kospi ? "kospi" : "kosdaq", + state.Selection.StockCode, + state.Selection.DisplayName, + _selected?.Selection == state.Selection); + + private static List ValidateResult( + TradingHaltSelectionResult result, + string expectedQuery, + int generation) + { + ArgumentNullException.ThrowIfNull(result); + if (!string.Equals(result.Query, expectedQuery, StringComparison.Ordinal) || + result.Items is null || + result.Items.Count > LegacyTradingHaltSelectionService.MaximumResults || + result.IsTruncated && + result.Items.Count != LegacyTradingHaltSelectionService.MaximumResults) + { + throw new TradingHaltSelectionDataException( + "The trading-halt search response correlation is invalid."); + } + + var states = new List(result.Items.Count); + var identities = new HashSet<(TradingHaltMarket Market, string Code)>(); + var names = new HashSet<(TradingHaltMarket Market, string Name)>(); + TradingHaltSelection? previous = null; + for (var index = 0; index < result.Items.Count; index++) + { + var item = result.Items[index] ?? + throw new TradingHaltSelectionDataException("A trading-halt row is missing."); + if (!Enum.IsDefined(item.Market) || + !IsAsciiAlphaNumeric(item.StockCode, 32) || + !IsCanonicalText(item.DisplayName, 200) || + !identities.Add((item.Market, item.StockCode)) || + !names.Add((item.Market, item.DisplayName)) || + previous is not null && CompareSourceOrder(previous, item) > 0) + { + throw new TradingHaltSelectionDataException( + "Trading-halt identities must be canonical, unique, and KOSPI/KOSDAQ ordered."); + } + + states.Add(new HaltState( + $"trading-halt-result-{generation:D4}-{index + 1:D4}", + item)); + previous = item; + } + + return states; + } + + private static int CompareSourceOrder( + TradingHaltSelection left, + TradingHaltSelection right) + { + var market = left.Market.CompareTo(right.Market); + if (market != 0) + { + return market; + } + + var name = string.Compare(left.DisplayName, right.DisplayName, StringComparison.Ordinal); + return name != 0 + ? name + : string.Compare(left.StockCode, right.StockCode, StringComparison.Ordinal); + } + + private static string NormalizeQuery(string query) + { + ArgumentNullException.ThrowIfNull(query); + var normalized = query.Trim(); + if (query.Length > LegacyTradingHaltSelectionService.MaximumQueryLength || + normalized.Length > LegacyTradingHaltSelectionService.MaximumQueryLength || + !IsSafeText(query) || !IsSafeText(normalized)) + { + throw new ArgumentException( + "A trading-halt query must contain at most 64 safe characters.", + nameof(query)); + } + + return normalized; + } + + private static bool IsAsciiAlphaNumeric(string? value, int maximumLength) => + value is not null && value.Length is > 0 && value.Length <= maximumLength && + value.All(static character => char.IsAsciiLetterOrDigit(character)); + + private static bool IsCanonicalText(string? value, int maximumLength) => + value is not null && value.Length is > 0 && value.Length <= maximumLength && + string.Equals(value, value.Trim(), StringComparison.Ordinal) && IsSafeText(value); + + private static bool IsSafeText(string value) + { + foreach (var character in value) + { + var category = CharUnicodeInfo.GetUnicodeCategory(character); + if (char.IsControl(character) || char.IsSurrogate(character) || + character == '\uFFFD' || + category is UnicodeCategory.Format or + UnicodeCategory.LineSeparator or + UnicodeCategory.ParagraphSeparator) + { + return false; + } + } + + return true; + } + + private void Touch() => _revision++; + + private sealed record HaltState( + string SelectionId, + TradingHaltSelection Selection); + + private sealed class DirectionComparer(int multiplier) : IComparer + { + public int Compare(string? left, string? right) => + multiplier * string.Compare(left, right, StringComparison.Ordinal); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs index 9c8d7f5..dc64dcf 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyBridgeProtocol.cs @@ -1,6 +1,9 @@ +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; using MBN_STOCK_WEBVIEW.LegacyApplication; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MMoneyCoderSharp.Data; namespace MBN_STOCK_WEBVIEW.LegacyBridge; @@ -26,6 +29,255 @@ public sealed record LegacyDropSelectedCutsIntent : LegacyUiIntent; public sealed record LegacySetPlaylistEnabledIntent(string RowId, bool Enabled) : LegacyUiIntent; +public sealed record LegacySetAllPlaylistEnabledIntent(bool Enabled) + : LegacyUiIntent; + +public sealed record LegacySelectPlaylistRowIntent( + string RowId, + bool Control, + bool Shift) : LegacyUiIntent; + +public sealed record LegacyMoveSelectedPlaylistRowsIntent( + LegacyPlaylistMoveDirection Direction) : LegacyUiIntent; + +public sealed record LegacyDeleteSelectedPlaylistRowsIntent : LegacyUiIntent; + +public sealed record LegacyDeleteAllPlaylistRowsIntent : LegacyUiIntent; + +public sealed record LegacyToggleActivePlaylistEnabledIntent : LegacyUiIntent; + +public sealed record LegacySelectPlaylistBoundaryIntent(bool Last) : LegacyUiIntent; + +public sealed record LegacySelectTabIntent(LegacyOperatorTabId TabId) : LegacyUiIntent; + +public sealed record LegacySwapTabsIntent( + LegacyOperatorTabId Source, + LegacyOperatorTabId Target) : LegacyUiIntent; + +public sealed record LegacySetMovingAveragesIntent(bool Ma5, bool Ma20) : LegacyUiIntent; + +public sealed record LegacyActivateFixedActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacyActivateFixedSectionIntent(int SectionIndex) : LegacyUiIntent; + +public sealed record LegacySelectIndustryIntent(int Index, bool DoubleClick) : LegacyUiIntent; + +public sealed record LegacySwapIndustriesIntent : LegacyUiIntent; + +public sealed record LegacyActivateIndustryActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacyActivateIndustrySectionIntent(int SectionIndex) : LegacyUiIntent; + +public sealed record LegacySelectOverseasFixedIndexIntent(string TargetId) : LegacyUiIntent; + +public sealed record LegacySearchOverseasIndustriesIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySearchOverseasStocksIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySelectOverseasIndustryIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacySelectOverseasStockIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacyActivateOverseasActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacySearchComparisonDomesticIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySearchComparisonWorldIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySelectComparisonDomesticIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacySelectComparisonWorldIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacySelectComparisonFixedIntent(string TargetId) : LegacyUiIntent; + +public sealed record LegacyChooseComparisonDomesticIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacyChooseComparisonWorldIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacyChooseComparisonFixedIntent(string TargetId) : LegacyUiIntent; + +public sealed record LegacySwapComparisonTargetsIntent : LegacyUiIntent; + +public sealed record LegacyClearComparisonTargetsIntent : LegacyUiIntent; + +public sealed record LegacyAddComparisonPairIntent : LegacyUiIntent; + +public sealed record LegacySelectComparisonPairIntent(string RowId) : LegacyUiIntent; + +public sealed record LegacyMoveComparisonPairIntent(LegacyComparisonPairMoveDirection Direction) + : LegacyUiIntent; + +public sealed record LegacyDeleteSelectedComparisonPairIntent : LegacyUiIntent; + +public sealed record LegacyDeleteAllComparisonPairsIntent : LegacyUiIntent; + +public sealed record LegacyActivateComparisonActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacyActivateComparisonSectionIntent(int SectionIndex) : LegacyUiIntent; + +public sealed record LegacySearchThemesIntent(string Query, ThemeSession NxtSession) + : LegacyUiIntent; + +public sealed record LegacySelectThemeIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacyActivateThemeResultIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacySetThemeSortIntent(string SortId) : LegacyUiIntent; + +public sealed record LegacyActivateThemeActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacySearchExpertsIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySelectExpertIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacyActivateExpertActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacySearchTradingHaltsIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySelectTradingHaltIntent(string SelectionId) : LegacyUiIntent; + +public sealed record LegacyToggleTradingHaltSortIntent : LegacyUiIntent; + +public sealed record LegacyActivateTradingHaltActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacyOpenOperatorCatalogIntent(LegacyOperatorCatalogEntity Entity) + : LegacyUiIntent; + +public sealed record LegacyBeginUc4ThemeCatalogIntent : LegacyUiIntent; + +public sealed record LegacyEditUc4SelectedThemeIntent : LegacyUiIntent; + +public sealed record LegacyDeleteUc4SelectedThemeIntent : LegacyUiIntent; + +public sealed record LegacyBeginUc6ExpertCatalogIntent : LegacyUiIntent; + +public sealed record LegacyEditUc6SelectedExpertIntent : LegacyUiIntent; + +public sealed record LegacyDeleteUc6SelectedExpertIntent : LegacyUiIntent; + +public sealed record LegacyCloseOperatorCatalogIntent : LegacyUiIntent; + +public sealed record LegacySearchOperatorCatalogIntent(string Query, ThemeSession NxtSession) + : LegacyUiIntent; + +public sealed record LegacySelectOperatorCatalogResultIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacyBeginCreateThemeCatalogIntent(ThemeMarket Market) : LegacyUiIntent; + +public sealed record LegacyBeginCreateExpertCatalogIntent : LegacyUiIntent; + +public sealed record LegacySearchOperatorCatalogStocksIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySelectOperatorCatalogStockIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacyAddOperatorCatalogStockIntent(decimal? BuyAmount) : LegacyUiIntent; + +public sealed record LegacyActivateOperatorCatalogStockIntent( + string ResultId, + decimal? BuyAmount) : LegacyUiIntent; + +public sealed record LegacyMoveOperatorCatalogDraftRowIntent( + string RowId, + LegacyOperatorCatalogMoveDirection Direction) : LegacyUiIntent; + +public sealed record LegacyRemoveOperatorCatalogDraftRowIntent(string RowId) : LegacyUiIntent; + +public sealed record LegacySetOperatorCatalogDraftBuyAmountIntent( + string RowId, + decimal BuyAmount) : LegacyUiIntent; + +public sealed record LegacySaveOperatorCatalogIntent(string Name) : LegacyUiIntent; + +public sealed record LegacyDeleteOperatorCatalogIntent : LegacyUiIntent; + +public sealed record LegacyOpenManualFinancialIntent(ManualFinancialScreenKind Screen) + : LegacyUiIntent; + +public sealed record LegacyCloseManualFinancialIntent : LegacyUiIntent; + +public sealed record LegacySearchManualFinancialIntent(string Query) : LegacyUiIntent; + +public sealed record LegacyFindManualFinancialIntent( + string Query, + LegacyManualFinancialFindDirection Direction, + long Generation) : LegacyUiIntent; + +public sealed record LegacyToggleManualFinancialNameSortIntent(long Generation) : LegacyUiIntent; + +public sealed record LegacyLoadManualFinancialIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacyActivateManualFinancialResultIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacyBeginManualFinancialCreateIntent : LegacyUiIntent; + +public sealed record LegacySearchManualFinancialStocksIntent(string StockName) : LegacyUiIntent; + +public sealed record LegacySelectManualFinancialStockIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacySaveManualFinancialIntent(ManualFinancialRecord Record) + : LegacyUiIntent; + +public sealed record LegacyDeleteManualFinancialIntent : LegacyUiIntent; + +public sealed record LegacyDeleteAllManualFinancialIntent : LegacyUiIntent; + +public sealed record LegacyActivateManualFinancialActionIntent(string ActionId) : LegacyUiIntent; + +public sealed record LegacyOpenManualListIntent( + LegacyManualListScreen Screen, + S5025ManualAudience Audience) : LegacyUiIntent; + +public sealed record LegacyCloseManualListIntent : LegacyUiIntent; + +public sealed record LegacyRefreshManualListIntent : LegacyUiIntent; + +public sealed record LegacySetManualNetSellCellIntent( + string RowId, + LegacyManualNetSellField Field, + string Value) : LegacyUiIntent; + +public sealed record LegacySaveManualNetSellIntent : LegacyUiIntent; + +public sealed record LegacyAddManualNetSellCutIntent : LegacyUiIntent; + +public sealed record LegacySearchManualViIntent(string Query) : LegacyUiIntent; + +public sealed record LegacySelectManualViResultIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacyAddManualViResultIntent(string ResultId) : LegacyUiIntent; + +public sealed record LegacyMoveManualViItemIntent( + string ItemId, + LegacyManualListMoveDirection Direction) : LegacyUiIntent; + +public sealed record LegacyDeleteManualViItemIntent(string ItemId) : LegacyUiIntent; + +public sealed record LegacyDeleteAllManualViItemsIntent : LegacyUiIntent; + +public sealed record LegacySaveManualViIntent : LegacyUiIntent; + +public sealed record LegacyAddManualViCutIntent : LegacyUiIntent; + +public sealed record LegacyConfirmManualListIntent : LegacyUiIntent; + +public sealed record LegacyImportManualListsIntent : LegacyUiIntent; + +public sealed record LegacyRefreshNamedPlaylistsIntent : LegacyUiIntent; + +public sealed record LegacySelectNamedPlaylistIntent(string DefinitionId) : LegacyUiIntent; + +public sealed record LegacyLoadSelectedNamedPlaylistIntent : LegacyUiIntent; + +public sealed record LegacyLoadNamedPlaylistByIdIntent(string DefinitionId) : LegacyUiIntent; + +public sealed record LegacyCreateNamedPlaylistIntent(string Title) : LegacyUiIntent; + +public sealed record LegacySaveCurrentNamedPlaylistIntent : LegacyUiIntent; + +public sealed record LegacySaveCurrentNamedPlaylistToIntent(string DefinitionId) : LegacyUiIntent; + +public sealed record LegacyDeleteSelectedNamedPlaylistIntent : LegacyUiIntent; + public sealed record LegacyDismissDialogIntent : LegacyUiIntent; public static class LegacyBridgeProtocol @@ -81,8 +333,179 @@ public static class LegacyBridgeProtocol "cut-pointer-down" => ParseCutPointer(payload), "drop-selected-cuts" => ParseDrop(payload), "set-playlist-enabled" => ParseEnabled(payload), + "set-all-playlist-enabled" => ParseAllEnabled(payload), + "select-playlist-row" => ParsePlaylistSelection(payload), + "move-selected-playlist-rows" => ParsePlaylistMove(payload), + "delete-selected-playlist-rows" => ParseDeleteSelected(payload), + "delete-all-playlist-rows" => ParseDeleteAll(payload), + "toggle-active-playlist-enabled" => ParseToggleActiveEnabled(payload), + "select-playlist-boundary" => ParsePlaylistBoundary(payload), + "select-tab" => ParseTabSelection(payload), + "swap-tabs" => ParseTabSwap(payload), + "set-moving-averages" => ParseMovingAverages(payload), + "activate-fixed-action" => ParseFixedAction(payload), + "activate-fixed-section" => ParseFixedSection(payload), + "select-industry" => ParseIndustrySelection(payload, false), + "double-click-industry" => ParseIndustrySelection(payload, true), + "swap-industries" => ParseSwapIndustries(payload), + "activate-industry-action" => ParseIndustryAction(payload), + "activate-industry-section" => ParseSectionIndex( + payload, + static index => new LegacyActivateIndustrySectionIntent(index)), + "select-overseas-fixed-index" => ParseOverseasFixedIndex(payload), + "search-overseas-industries" => ParseOverseasSearch(payload, false), + "search-overseas-stocks" => ParseOverseasSearch(payload, true), + "select-overseas-industry" => ParseOverseasSelection(payload, false), + "select-overseas-stock" => ParseOverseasSelection(payload, true), + "activate-overseas-action" => ParseOverseasAction(payload), + "search-comparison-domestic" => ParseComparisonSearch(payload, false), + "search-comparison-world" => ParseComparisonSearch(payload, true), + "select-comparison-domestic" => ParseComparisonSelection(payload, false), + "select-comparison-world" => ParseComparisonSelection(payload, true), + "select-comparison-fixed" => ParseComparisonFixedSelection(payload), + "choose-comparison-domestic" => ParseComparisonChoice(payload, false), + "choose-comparison-world" => ParseComparisonChoice(payload, true), + "choose-comparison-fixed" => ParseComparisonFixed(payload), + "swap-comparison-targets" => ParseComparisonSwap(payload), + "clear-comparison-targets" => ParseComparisonClear(payload), + "add-comparison-pair" => ParseComparisonAdd(payload), + "select-comparison-pair" => ParseComparisonPairSelection(payload), + "move-comparison-pair" => ParseComparisonPairMove(payload), + "delete-selected-comparison-pair" => ParseComparisonDeleteSelected(payload), + "delete-all-comparison-pairs" => ParseComparisonDeleteAll(payload), + "activate-comparison-action" => ParseComparisonAction(payload), + "activate-comparison-section" => ParseSectionIndex( + payload, + static index => new LegacyActivateComparisonSectionIntent(index)), + "search-themes" => ParseThemeSearch(payload), + "select-theme" => ParseThemeSelection(payload), + "activate-theme-result" => ParseThemeResultActivation(payload), + "set-theme-sort" => ParseThemeSort(payload), + "activate-theme-action" => ParseThemeAction(payload), + "search-experts" => ParseExpertSearch(payload), + "select-expert" => ParseExpertSelection(payload), + "activate-expert-action" => ParseExpertAction(payload), + "search-trading-halts" => ParseTradingHaltSearch(payload), + "select-trading-halt" => ParseTradingHaltSelection(payload), + "toggle-trading-halt-sort" => ParseTradingHaltSort(payload), + "activate-trading-halt-action" => ParseTradingHaltAction(payload), + "open-operator-catalog" => ParseOperatorCatalogOpen(payload), + "begin-uc4-theme-catalog" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyBeginUc4ThemeCatalogIntent()), + "edit-uc4-selected-theme" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyEditUc4SelectedThemeIntent()), + "delete-uc4-selected-theme" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyDeleteUc4SelectedThemeIntent()), + "begin-uc6-expert-catalog" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyBeginUc6ExpertCatalogIntent()), + "edit-uc6-selected-expert" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyEditUc6SelectedExpertIntent()), + "delete-uc6-selected-expert" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyDeleteUc6SelectedExpertIntent()), + "close-operator-catalog" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyCloseOperatorCatalogIntent()), + "search-operator-catalog" => ParseOperatorCatalogSearch(payload), + "select-operator-catalog-result" => ParseOperatorCatalogResult(payload), + "begin-create-theme-catalog" => ParseOperatorCatalogThemeCreate(payload), + "begin-create-expert-catalog" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyBeginCreateExpertCatalogIntent()), + "search-operator-catalog-stocks" => ParseOperatorCatalogStockSearch(payload), + "select-operator-catalog-stock" => ParseOperatorCatalogStockSelection(payload), + "add-operator-catalog-stock" => ParseOperatorCatalogStockAdd(payload), + "activate-operator-catalog-stock" => + ParseOperatorCatalogStockActivation(payload), + "move-operator-catalog-draft-row" => ParseOperatorCatalogDraftMove(payload), + "remove-operator-catalog-draft-row" => ParseOperatorCatalogDraftRemove(payload), + "set-operator-catalog-draft-buy-amount" => + ParseOperatorCatalogDraftBuyAmount(payload), + "save-operator-catalog" => ParseOperatorCatalogSave(payload), + "delete-operator-catalog" => ParseOperatorCatalogEmpty( + payload, + static () => new LegacyDeleteOperatorCatalogIntent()), + "refresh-named-playlists" => ParseNamedPlaylistEmpty( + payload, + static () => new LegacyRefreshNamedPlaylistsIntent()), + "select-named-playlist" => ParseNamedPlaylistSelection(payload), + "load-named-playlist-by-id" => ParseNamedPlaylistLoadById(payload), + "load-selected-named-playlist" => ParseNamedPlaylistEmpty( + payload, + static () => new LegacyLoadSelectedNamedPlaylistIntent()), + "create-named-playlist" => ParseNamedPlaylistCreate(payload), + "save-current-named-playlist" => ParseNamedPlaylistEmpty( + payload, + static () => new LegacySaveCurrentNamedPlaylistIntent()), + "save-current-named-playlist-to" => ParseNamedPlaylistSaveTo(payload), + "delete-selected-named-playlist" => ParseNamedPlaylistEmpty( + payload, + static () => new LegacyDeleteSelectedNamedPlaylistIntent()), + "open-manual-financial" => ParseManualFinancialOpen(payload), + "close-manual-financial" => ParseManualFinancialEmpty( + payload, + static () => new LegacyCloseManualFinancialIntent()), + "search-manual-financial" => ParseManualFinancialSearch(payload), + "find-manual-financial" => ParseManualFinancialFind(payload), + "toggle-manual-financial-name-sort" => + ParseManualFinancialNameSort(payload), + "load-manual-financial" => ParseManualFinancialLoad(payload), + "activate-manual-financial-result" => + ParseManualFinancialResultActivation(payload), + "begin-manual-financial-create" => ParseManualFinancialEmpty( + payload, + static () => new LegacyBeginManualFinancialCreateIntent()), + "search-manual-financial-stocks" => ParseManualFinancialStockSearch(payload), + "select-manual-financial-stock" => ParseManualFinancialStockSelection(payload), + "save-manual-financial" => ParseManualFinancialSave(payload), + "delete-manual-financial" => ParseManualFinancialEmpty( + payload, + static () => new LegacyDeleteManualFinancialIntent()), + "delete-all-manual-financial" => ParseManualFinancialEmpty( + payload, + static () => new LegacyDeleteAllManualFinancialIntent()), + "activate-manual-financial-action" => ParseManualFinancialAction(payload), + "open-manual-list" => ParseManualListOpen(payload), + "close-manual-list" => ParseManualListEmpty( + payload, + static () => new LegacyCloseManualListIntent()), + "refresh-manual-list" => ParseManualListEmpty( + payload, + static () => new LegacyRefreshManualListIntent()), + "set-manual-net-sell-cell" => ParseManualNetSellCell(payload), + "save-manual-net-sell" => ParseManualListEmpty( + payload, + static () => new LegacySaveManualNetSellIntent()), + "add-manual-net-sell-cut" => ParseManualListEmpty( + payload, + static () => new LegacyAddManualNetSellCutIntent()), + "search-manual-vi" => ParseManualViSearch(payload), + "select-manual-vi-result" => ParseManualViSelection(payload), + "add-manual-vi-result" => ParseManualViResult(payload), + "move-manual-vi-item" => ParseManualViMove(payload), + "delete-manual-vi-item" => ParseManualViDelete(payload), + "delete-all-manual-vi-items" => ParseManualListEmpty( + payload, + static () => new LegacyDeleteAllManualViItemsIntent()), + "save-manual-vi" => ParseManualListEmpty( + payload, + static () => new LegacySaveManualViIntent()), + "add-manual-vi-cut" => ParseManualListEmpty( + payload, + static () => new LegacyAddManualViCutIntent()), + "confirm-manual-list" => ParseManualListEmpty( + payload, + static () => new LegacyConfirmManualListIntent()), + "import-manual-lists" => ParseManualListEmpty( + payload, + static () => new LegacyImportManualListsIntent()), "dismiss-dialog" => ParseDismiss(payload), - _ => null + _ => LegacyPlayoutUiIntentParser.Parse(type, payload) }; if (intent is null) @@ -102,7 +525,8 @@ public static class LegacyBridgeProtocol public static string SerializeState( LegacyOperatorSnapshot snapshot, - bool isBusy = false) + bool isBusy = false, + LegacyOperatorPlayoutSnapshot? playout = null) { ArgumentNullException.ThrowIfNull(snapshot); var payload = new @@ -116,6 +540,7 @@ public static class LegacyBridgeProtocol { row.RowId, row.IsEnabled, + row.IsSelected, row.MarketText, row.StockName, row.GraphicType, @@ -125,6 +550,471 @@ public static class LegacyBridgeProtocol snapshot.Dialog, snapshot.StatusMessage, snapshot.StatusKind, + commandResult = snapshot.CommandResult is null ? null : new + { + snapshot.CommandResult.Sequence, + snapshot.CommandResult.Command, + snapshot.CommandResult.TargetId, + snapshot.CommandResult.Succeeded + }, + tabs = snapshot.Tabs ?? [], + snapshot.MovingAverages, + snapshot.FixedCatalog, + snapshot.FixedSectionBatch, + industry = snapshot.Industry is null ? null : new + { + snapshot.Industry.Market, + industries = snapshot.Industry.Industries.Select((row, index) => new + { + index, + name = row.IndustryName + }), + snapshot.Industry.SelectedIndex, + selectedName = snapshot.Industry.SelectedIndustry?.IndustryName, + firstName = snapshot.Industry.FirstIndustry?.IndustryName, + secondName = snapshot.Industry.SecondIndustry?.IndustryName, + actions = snapshot.Industry.Actions.Select(action => new + { + actionId = action.ActionId, + action.Label, + action.Section, + action.Category + }) + }, + overseas = snapshot.Overseas is null ? null : new + { + snapshot.Overseas.Revision, + fixedIndexTargets = snapshot.Overseas.FixedIndexTargets.Select(target => new + { + target.TargetId, + target.DisplayName + }), + selectedFixedIndexId = snapshot.Overseas.SelectedFixedIndex?.TargetId, + selectedFixedIndexName = snapshot.Overseas.SelectedFixedIndex?.DisplayName, + industry = new + { + snapshot.Overseas.Industry.Status, + snapshot.Overseas.Industry.Query, + snapshot.Overseas.Industry.ResultCount, + snapshot.Overseas.Industry.IsTruncated, + snapshot.Overseas.Industry.SelectedId, + selectedName = snapshot.Overseas.Industry.Selected?.KoreanName, + snapshot.Overseas.Industry.Error, + results = snapshot.Overseas.Industry.Results.Select(row => new + { + row.SelectionId, + displayName = row.KoreanName + }) + }, + stock = new + { + snapshot.Overseas.Stock.Status, + snapshot.Overseas.Stock.Query, + snapshot.Overseas.Stock.ResultCount, + snapshot.Overseas.Stock.IsTruncated, + snapshot.Overseas.Stock.SelectedId, + selectedName = snapshot.Overseas.Stock.Selected?.InputName, + snapshot.Overseas.Stock.Error, + results = snapshot.Overseas.Stock.Results.Select(row => new + { + row.SelectionId, + displayName = row.InputName, + row.NationCode + }) + }, + actions = snapshot.Overseas.Actions.Select(action => new + { + action.ActionId, + action.Label, + action.Source, + action.Kind, + action.PeriodDays, + isAvailable = action.Source switch + { + LegacyOverseasActionSource.FixedIndex => + snapshot.Overseas.SelectedFixedIndex is not null, + LegacyOverseasActionSource.Industry => + snapshot.Overseas.Industry.Selected is not null, + LegacyOverseasActionSource.Stock => + snapshot.Overseas.Stock.Selected is not null, + _ => false + } + }) + }, + comparison = snapshot.Comparison is null ? null : new + { + snapshot.Comparison.IsBusy, + domesticSearch = new + { + snapshot.Comparison.DomesticSearch.Query, + snapshot.Comparison.DomesticSearch.Status, + results = snapshot.Comparison.DomesticSearch.Results.Select(row => new + { + row.SelectionId, + row.DisplayName, + row.Metadata, + row.Kind + }), + snapshot.Comparison.DomesticSearch.SelectedResultId, + snapshot.Comparison.DomesticSearch.TotalRowCount, + snapshot.Comparison.DomesticSearch.ErrorMessage + }, + worldSearch = new + { + snapshot.Comparison.WorldSearch.Query, + snapshot.Comparison.WorldSearch.Status, + results = snapshot.Comparison.WorldSearch.Results.Select(row => new + { + row.SelectionId, + row.DisplayName, + row.Metadata, + row.Kind + }), + snapshot.Comparison.WorldSearch.SelectedResultId, + snapshot.Comparison.WorldSearch.TotalRowCount, + snapshot.Comparison.WorldSearch.ErrorMessage + }, + fixedTargets = snapshot.Comparison.FixedTargets.Select(row => new + { + targetId = row.SelectionId, + row.DisplayName, + row.Metadata, + row.Kind + }), + snapshot.Comparison.SelectedFixedTargetId, + firstTarget = snapshot.Comparison.FirstTarget is null ? null : new + { + snapshot.Comparison.FirstTarget.SelectionId, + snapshot.Comparison.FirstTarget.DisplayName, + snapshot.Comparison.FirstTarget.Metadata, + snapshot.Comparison.FirstTarget.Kind + }, + secondTarget = snapshot.Comparison.SecondTarget is null ? null : new + { + snapshot.Comparison.SecondTarget.SelectionId, + snapshot.Comparison.SecondTarget.DisplayName, + snapshot.Comparison.SecondTarget.Metadata, + snapshot.Comparison.SecondTarget.Kind + }, + savedPairs = snapshot.Comparison.SavedPairs.Select(row => new + { + row.RowId, + row.RowNumber, + row.DisplayLabel, + row.IsSelected + }), + snapshot.Comparison.SelectedPairId, + snapshot.Comparison.Actions, + snapshot.Comparison.StatusMessage, + snapshot.Comparison.StatusKind + }, + theme = snapshot.Theme is null ? null : new + { + snapshot.Theme.Query, + snapshot.Theme.NxtSession, + snapshot.Theme.SortId, + searchResults = snapshot.Theme.SearchResults.Select(row => new + { + row.ResultId, + row.DisplayTitle, + row.Market, + row.Session + }), + snapshot.Theme.SearchIsTruncated, + snapshot.Theme.SelectedResultId, + snapshot.Theme.SelectedTheme, + previewItems = snapshot.Theme.PreviewItems.Select(row => new + { + row.InputIndex, + row.ItemName + }), + snapshot.Theme.PreviewIsTruncated, + sorts = LegacyThemeWorkflow.Sorts.Select(sort => new + { + sort.Id, + sort.Label + }), + actions = LegacyThemeWorkflow.Actions.Select(action => + { + var state = snapshot.Theme.ActionStates.Single(item => + string.Equals(item.ActionId, action.Id, StringComparison.Ordinal)); + return new + { + actionId = action.Id, + action.Label, + state.IsAvailable, + state.UnavailableReason + }; + }) + }, + expert = snapshot.Expert is null ? null : new + { + search = new + { + snapshot.Expert.Search.Query, + snapshot.Expert.Search.Status, + results = snapshot.Expert.Search.Results.Select(row => new + { + row.SelectionId, + row.ExpertName + }), + snapshot.Expert.Search.IsTruncated, + snapshot.Expert.Search.ErrorMessage + }, + selectedExpert = snapshot.Expert.SelectedExpert is null ? null : new + { + snapshot.Expert.SelectedExpert.SelectionId, + snapshot.Expert.SelectedExpert.ExpertName + }, + preview = new + { + snapshot.Expert.Preview.Status, + items = snapshot.Expert.Preview.Items.Select(row => new + { + row.PlayIndex, + row.StockName, + row.BuyAmount + }), + snapshot.Expert.Preview.IsTruncated, + snapshot.Expert.Preview.PageCount, + snapshot.Expert.Preview.MaximumPageCount, + snapshot.Expert.Preview.ErrorMessage + }, + snapshot.Expert.Actions, + snapshot.Expert.StatusMessage, + snapshot.Expert.StatusKind + }, + tradingHalt = snapshot.TradingHalt is null ? null : new + { + snapshot.TradingHalt.Query, + snapshot.TradingHalt.Status, + results = snapshot.TradingHalt.Results.Select(row => new + { + row.SelectionId, + row.Market, + row.DisplayName, + row.IsSelected + }), + selected = snapshot.TradingHalt.Selected is null ? null : new + { + snapshot.TradingHalt.Selected.SelectionId, + snapshot.TradingHalt.Selected.Market, + snapshot.TradingHalt.Selected.DisplayName + }, + snapshot.TradingHalt.IsTruncated, + snapshot.TradingHalt.NameSort, + snapshot.TradingHalt.Ma5, + snapshot.TradingHalt.Ma20, + snapshot.TradingHalt.Actions, + snapshot.TradingHalt.ErrorMessage, + snapshot.TradingHalt.StatusMessage, + snapshot.TradingHalt.StatusKind + }, + operatorCatalog = snapshot.OperatorCatalog is null ? null : new + { + snapshot.OperatorCatalog.Revision, + snapshot.OperatorCatalog.IsOpen, + snapshot.OperatorCatalog.Entity, + snapshot.OperatorCatalog.Mode, + snapshot.OperatorCatalog.Query, + snapshot.OperatorCatalog.NxtSession, + snapshot.OperatorCatalog.NewThemeMarket, + results = snapshot.OperatorCatalog.Results.Select(row => new + { + row.ResultId, + row.CodeLabel, + row.DisplayName, + row.Scope, + row.IsSelected + }), + snapshot.OperatorCatalog.ResultsAreTruncated, + snapshot.OperatorCatalog.SelectedResultId, + snapshot.OperatorCatalog.CodeLabel, + snapshot.OperatorCatalog.EditorName, + draftRows = snapshot.OperatorCatalog.DraftRows.Select(row => new + { + row.RowId, + row.DisplayName, + row.Market, + row.BuyAmount, + row.Position + }), + snapshot.OperatorCatalog.StockQuery, + stockResults = snapshot.OperatorCatalog.StockResults.Select(row => new + { + row.ResultId, + row.DisplayName, + row.Market, + row.IsCompatible, + row.IsSelected + }), + snapshot.OperatorCatalog.StockResultsAreTruncated, + snapshot.OperatorCatalog.SelectedStockResultId, + snapshot.OperatorCatalog.SchemaValidated, + snapshot.OperatorCatalog.WritesQuarantined, + snapshot.OperatorCatalog.CanBeginCreate, + snapshot.OperatorCatalog.CanSave, + snapshot.OperatorCatalog.CanDelete, + snapshot.OperatorCatalog.CanAddStock, + snapshot.OperatorCatalog.StatusMessage, + snapshot.OperatorCatalog.StatusKind + }, + manualFinancial = snapshot.ManualFinancial is null ? null : new + { + screen = ManualFinancialScreenId(snapshot.ManualFinancial.Screen), + snapshot.ManualFinancial.Definition.Label, + generation = snapshot.ManualFinancial.Revision, + snapshot.ManualFinancial.Query, + snapshot.ManualFinancial.FindQuery, + nameSortDirection = snapshot.ManualFinancial.NameSortDirection == + LegacyManualFinancialNameSortDirection.Ascending + ? "ascending" + : "descending", + rows = snapshot.ManualFinancial.Rows.Select(row => new + { + row.ResultId, + row.StockName + }), + snapshot.ManualFinancial.ListIsTruncated, + snapshot.ManualFinancial.SelectedRowId, + selectedRecord = ProjectManualFinancialRecord( + snapshot.ManualFinancial.SelectedRecord), + snapshot.ManualFinancial.IsDetailFresh, + snapshot.ManualFinancial.StockQuery, + stockCandidates = snapshot.ManualFinancial.StockCandidates.Select(row => new + { + row.ResultId, + row.Market, + row.Name + }), + snapshot.ManualFinancial.StockSearchIsTruncated, + snapshot.ManualFinancial.SelectedStockResultId, + verifiedStock = snapshot.ManualFinancial.VerifiedStock is null ? null : new + { + snapshot.ManualFinancial.VerifiedStock.Market, + snapshot.ManualFinancial.VerifiedStock.Name + }, + snapshot.ManualFinancial.Status, + snapshot.ManualFinancial.WritesQuarantined, + snapshot.ManualFinancial.LastMessage, + snapshot.ManualFinancial.CanSave, + snapshot.ManualFinancial.CanDelete, + snapshot.ManualFinancial.CanMaterializePlaylist, + action = new + { + snapshot.ManualFinancial.Definition.ActionId, + snapshot.ManualFinancial.Definition.Label + } + }, + manualLists = snapshot.ManualLists is null ? null : new + { + snapshot.ManualLists.IsOpen, + screen = snapshot.ManualLists.Screen switch + { + LegacyManualListScreen.NetSell => "net-sell", + LegacyManualListScreen.Vi => "vi", + _ => null + }, + audience = snapshot.ManualLists.Audience switch + { + S5025ManualAudience.Individual => "individual", + S5025ManualAudience.Foreign => "foreign", + S5025ManualAudience.Institution => "institution", + _ => throw new ArgumentOutOfRangeException() + }, + snapshot.ManualLists.IsAvailable, + snapshot.ManualLists.AvailabilityCode, + snapshot.ManualLists.AvailabilityMessage, + snapshot.ManualLists.WritesQuarantined, + netRows = snapshot.ManualLists.NetRows.Select(row => new + { + row.RowId, + row.LeftName, + row.LeftAmount, + row.RightName, + row.RightAmount + }), + snapshot.ManualLists.NetRowsAreFresh, + viItems = snapshot.ManualLists.ViItems.Select(row => new + { + row.ItemId, + row.Index, + row.Name + }), + snapshot.ManualLists.ViPageCount, + snapshot.ManualLists.ViItemsAreFresh, + snapshot.ManualLists.SearchQuery, + searchResults = snapshot.ManualLists.SearchResults.Select(row => new + { + row.ResultId, + row.DisplayName, + row.MarketText + }), + snapshot.ManualLists.SelectedSearchResultId, + snapshot.ManualLists.StatusMessage, + snapshot.ManualLists.StatusKind, + snapshot.ManualLists.CanSave, + snapshot.ManualLists.CanMaterializePlaylist + }, + namedPlaylist = snapshot.NamedPlaylist is null ? null : new + { + snapshot.NamedPlaylist.Revision, + snapshot.NamedPlaylist.ReadStatus, + definitions = snapshot.NamedPlaylist.Definitions.Select(definition => new + { + definition.DefinitionId, + definition.Title, + definition.IsSelected + }), + snapshot.NamedPlaylist.IsListTruncated, + snapshot.NamedPlaylist.SelectedDefinitionId, + snapshot.NamedPlaylist.SelectedTitle, + rowCount = snapshot.NamedPlaylist.Rows.Count, + snapshot.NamedPlaylist.ListFreshness, + snapshot.NamedPlaylist.DocumentFreshness, + snapshot.NamedPlaylist.IsWriteInProgress, + snapshot.NamedPlaylist.IsWriteQuarantined, + snapshot.NamedPlaylist.CanMutate, + snapshot.NamedPlaylist.CanCreate, + snapshot.NamedPlaylist.CanLoad, + snapshot.NamedPlaylist.CanSave, + snapshot.NamedPlaylist.CanDelete, + snapshot.NamedPlaylist.LastMutationOutcome, + snapshot.NamedPlaylist.StatusMessage, + snapshot.NamedPlaylist.StatusKind + }, + playout = playout is null ? null : new + { + playout.Mode, + playout.ConnectionState, + playout.Phase, + playout.IsProcessRunning, + playout.IsConnected, + playout.IsCommandAvailable, + playout.LiveTakeInAllowed, + playout.IsPlayCompletionPending, + playout.OutcomeUnknown, + playout.PreparedCode, + playout.OnAirCode, + playout.CurrentCueIndexZeroBased, + playout.PageIndexZeroBased, + playout.PageCount, + playout.PageSize, + playout.ItemCount, + playout.CurrentPageItemCount, + playout.IsLastPage, + playout.NextKind, + playout.IsBusy, + playout.RefreshActive, + playout.RefreshCompletedCount, + playout.RefreshMaximumCount, + playout.RefreshLimitReached, + playout.FadeDuration, + playout.BackgroundEnabled, + playout.BackgroundFileName, + playout.CanChangeBackground, + playout.Message + }, isBusy }; @@ -207,6 +1097,1178 @@ public static class LegacyBridgeProtocol return new LegacySetPlaylistEnabledIntent(rowId, enabled); } + private static LegacyUiIntent? ParseAllEnabled(JsonElement payload) => + HasOnlyProperties(payload, "enabled") && + TryBoolean(payload, "enabled", out var enabled) + ? new LegacySetAllPlaylistEnabledIntent(enabled) + : null; + + private static LegacyUiIntent? ParsePlaylistSelection(JsonElement payload) + { + if (!HasOnlyProperties(payload, "rowId", "control", "shift") || + !TryString(payload, "rowId", 128, out var rowId) || + !IsSafeIdentifier(rowId) || + !TryBoolean(payload, "control", out var control) || + !TryBoolean(payload, "shift", out var shift)) + { + return null; + } + + return new LegacySelectPlaylistRowIntent(rowId, control, shift); + } + + private static LegacyUiIntent? ParsePlaylistMove(JsonElement payload) + { + if (!HasOnlyProperties(payload, "direction") || + !TryString(payload, "direction", 8, out var directionText)) + { + return null; + } + + var direction = directionText switch + { + "up" => LegacyPlaylistMoveDirection.Up, + "down" => LegacyPlaylistMoveDirection.Down, + _ => (LegacyPlaylistMoveDirection?)null + }; + return direction.HasValue + ? new LegacyMoveSelectedPlaylistRowsIntent(direction.Value) + : null; + } + + private static LegacyUiIntent? ParseDeleteSelected(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyDeleteSelectedPlaylistRowsIntent() : null; + + private static LegacyUiIntent? ParseDeleteAll(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyDeleteAllPlaylistRowsIntent() : null; + + private static LegacyUiIntent? ParseToggleActiveEnabled(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyToggleActivePlaylistEnabledIntent() : null; + + private static LegacyUiIntent? ParsePlaylistBoundary(JsonElement payload) + { + if (!HasOnlyProperties(payload, "boundary") || + !TryString(payload, "boundary", 8, out var boundary)) + { + return null; + } + + return boundary switch + { + "first" => new LegacySelectPlaylistBoundaryIntent(false), + "last" => new LegacySelectPlaylistBoundaryIntent(true), + _ => null + }; + } + + private static LegacyUiIntent? ParseTabSelection(JsonElement payload) => + HasOnlyProperties(payload, "tabId") && + TryTabId(payload, "tabId", out var tabId) + ? new LegacySelectTabIntent(tabId) + : null; + + private static LegacyUiIntent? ParseTabSwap(JsonElement payload) => + HasOnlyProperties(payload, "source", "target") && + TryTabId(payload, "source", out var source) && + TryTabId(payload, "target", out var target) + ? new LegacySwapTabsIntent(source, target) + : null; + + private static LegacyUiIntent? ParseMovingAverages(JsonElement payload) => + HasOnlyProperties(payload, "ma5", "ma20") && + TryBoolean(payload, "ma5", out var ma5) && + TryBoolean(payload, "ma20", out var ma20) + ? new LegacySetMovingAveragesIntent(ma5, ma20) + : null; + + private static LegacyUiIntent? ParseFixedAction(JsonElement payload) => + HasOnlyProperties(payload, "actionId") && + TryString(payload, "actionId", 64, out var actionId) && + IsSafeIdentifier(actionId) + ? new LegacyActivateFixedActionIntent(actionId) + : null; + + private static LegacyUiIntent? ParseFixedSection(JsonElement payload) => + HasOnlyProperties(payload, "sectionIndex") && + TryInt32(payload, "sectionIndex", 0, 255, out var sectionIndex) + ? new LegacyActivateFixedSectionIntent(sectionIndex) + : null; + + private static LegacyUiIntent? ParseIndustrySelection( + JsonElement payload, + bool doubleClick) => + HasOnlyProperties(payload, "index") && + TryInt32(payload, "index", 0, 499, out var index) + ? new LegacySelectIndustryIntent(index, doubleClick) + : null; + + private static LegacyUiIntent? ParseSwapIndustries(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacySwapIndustriesIntent() : null; + + private static LegacyUiIntent? ParseIndustryAction(JsonElement payload) => + HasOnlyProperties(payload, "actionId") && + TryString(payload, "actionId", 64, out var actionId) && + IsSafeOpaqueActionId(actionId) + ? new LegacyActivateIndustryActionIntent(actionId) + : null; + + private static LegacyUiIntent? ParseOverseasFixedIndex(JsonElement payload) => + HasOnlyProperties(payload, "targetId") && + TryString(payload, "targetId", 64, out var targetId) && + IsSafeIdentifier(targetId) && + LegacyOverseasActionCatalog.FindFixedIndexTarget(targetId) is not null + ? new LegacySelectOverseasFixedIndexIntent(targetId) + : null; + + private static LegacyUiIntent? ParseOverseasSearch( + JsonElement payload, + bool stocks) => + HasOnlyProperties(payload, "query") && + TrySafeString( + payload, + "query", + LegacyOverseasSelectionWorkflow.MaximumQueryLength, + allowEmpty: true, + allowUnderscore: true, + out var query) + ? stocks + ? new LegacySearchOverseasStocksIntent(query) + : new LegacySearchOverseasIndustriesIntent(query) + : null; + + private static LegacyUiIntent? ParseOverseasSelection( + JsonElement payload, + bool stocks) => + HasOnlyProperties(payload, "selectionId") && + TryString(payload, "selectionId", 128, out var selectionId) && + IsSafeIdentifier(selectionId) + ? stocks + ? new LegacySelectOverseasStockIntent(selectionId) + : new LegacySelectOverseasIndustryIntent(selectionId) + : null; + + private static LegacyUiIntent? ParseOverseasAction(JsonElement payload) => + HasOnlyProperties(payload, "actionId") && + TryString(payload, "actionId", 64, out var actionId) && + IsSafeIdentifier(actionId) && + LegacyOverseasActionCatalog.FindAction(actionId) is not null + ? new LegacyActivateOverseasActionIntent(actionId) + : null; + + private static LegacyUiIntent? ParseComparisonSearch( + JsonElement payload, + bool world) => + HasOnlyProperties(payload, "query") && + TryString(payload, "query", 64, out var query) + ? world + ? new LegacySearchComparisonWorldIntent(query) + : new LegacySearchComparisonDomesticIntent(query) + : null; + + private static LegacyUiIntent? ParseComparisonChoice( + JsonElement payload, + bool world) => + HasOnlyProperties(payload, "selectionId") && + TryString(payload, "selectionId", 128, out var selectionId) && + IsSafeIdentifier(selectionId) + ? world + ? new LegacyChooseComparisonWorldIntent(selectionId) + : new LegacyChooseComparisonDomesticIntent(selectionId) + : null; + + private static LegacyUiIntent? ParseComparisonSelection( + JsonElement payload, + bool world) => + HasOnlyProperties(payload, "selectionId") && + TryString(payload, "selectionId", 128, out var selectionId) && + IsSafeIdentifier(selectionId) + ? world + ? new LegacySelectComparisonWorldIntent(selectionId) + : new LegacySelectComparisonDomesticIntent(selectionId) + : null; + + private static LegacyUiIntent? ParseComparisonFixedSelection(JsonElement payload) => + HasOnlyProperties(payload, "targetId") && + TryString(payload, "targetId", 64, out var targetId) && + IsSafeIdentifier(targetId) + ? new LegacySelectComparisonFixedIntent(targetId) + : null; + + private static LegacyUiIntent? ParseComparisonFixed(JsonElement payload) => + HasOnlyProperties(payload, "targetId") && + TryString(payload, "targetId", 64, out var targetId) && + IsSafeIdentifier(targetId) + ? new LegacyChooseComparisonFixedIntent(targetId) + : null; + + private static LegacyUiIntent? ParseComparisonSwap(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacySwapComparisonTargetsIntent() : null; + + private static LegacyUiIntent? ParseComparisonClear(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyClearComparisonTargetsIntent() : null; + + private static LegacyUiIntent? ParseComparisonAdd(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyAddComparisonPairIntent() : null; + + private static LegacyUiIntent? ParseComparisonPairSelection(JsonElement payload) => + HasOnlyProperties(payload, "rowId") && + TryString(payload, "rowId", 128, out var rowId) && + IsSafeIdentifier(rowId) + ? new LegacySelectComparisonPairIntent(rowId) + : null; + + private static LegacyUiIntent? ParseComparisonPairMove(JsonElement payload) + { + if (!HasOnlyProperties(payload, "direction") || + !TryString(payload, "direction", 8, out var direction)) + { + return null; + } + + return direction switch + { + "up" => new LegacyMoveComparisonPairIntent( + LegacyComparisonPairMoveDirection.Up), + "down" => new LegacyMoveComparisonPairIntent( + LegacyComparisonPairMoveDirection.Down), + _ => null + }; + } + + private static LegacyUiIntent? ParseComparisonDeleteSelected(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyDeleteSelectedComparisonPairIntent() : null; + + private static LegacyUiIntent? ParseComparisonDeleteAll(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyDeleteAllComparisonPairsIntent() : null; + + private static LegacyUiIntent? ParseComparisonAction(JsonElement payload) => + HasOnlyProperties(payload, "actionId") && + TryString(payload, "actionId", 64, out var actionId) && + IsSafeIdentifier(actionId) + ? new LegacyActivateComparisonActionIntent(actionId) + : null; + + private static LegacyUiIntent? ParseSectionIndex( + JsonElement payload, + Func factory) => + HasOnlyProperties(payload, "sectionIndex") && + TryInt32(payload, "sectionIndex", 0, 63, out var sectionIndex) + ? factory(sectionIndex) + : null; + + private static LegacyUiIntent? ParseThemeSearch(JsonElement payload) + { + if (!HasOnlyProperties(payload, "query", "nxtSession") || + !TryString( + payload, + "query", + LegacyThemeSelectionService.MaximumQueryLength, + out var query) || + !TryString(payload, "nxtSession", 16, out var sessionText)) + { + return null; + } + + var session = sessionText switch + { + "preMarket" => ThemeSession.PreMarket, + "afterMarket" => ThemeSession.AfterMarket, + _ => (ThemeSession?)null + }; + return session.HasValue + ? new LegacySearchThemesIntent(query, session.Value) + : null; + } + + private static LegacyUiIntent? ParseThemeSelection(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 64, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacySelectThemeIntent(resultId) + : null; + + private static LegacyUiIntent? ParseThemeResultActivation(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 64, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacyActivateThemeResultIntent(resultId) + : null; + + private static LegacyUiIntent? ParseThemeSort(JsonElement payload) + { + if (!HasOnlyProperties(payload, "sortId") || + !TryString(payload, "sortId", 24, out var sortId)) + { + return null; + } + + return sortId is "INPUT_ORDER" or "GAIN_DESC" or "GAIN_ASC" + ? new LegacySetThemeSortIntent(sortId) + : null; + } + + private static LegacyUiIntent? ParseThemeAction(JsonElement payload) => + HasOnlyProperties(payload, "actionId") && + TryString(payload, "actionId", 64, out var actionId) && + IsSafeIdentifier(actionId) + ? new LegacyActivateThemeActionIntent(actionId) + : null; + + private static LegacyUiIntent? ParseExpertSearch(JsonElement payload) => + HasOnlyProperties(payload, "query") && + TryString( + payload, + "query", + LegacyExpertSelectionService.MaximumQueryLength, + out var query) + ? new LegacySearchExpertsIntent(query) + : null; + + private static LegacyUiIntent? ParseExpertSelection(JsonElement payload) => + HasOnlyProperties(payload, "selectionId") && + TryString(payload, "selectionId", 128, out var selectionId) && + IsSafeIdentifier(selectionId) + ? new LegacySelectExpertIntent(selectionId) + : null; + + private static LegacyUiIntent? ParseExpertAction(JsonElement payload) => + HasOnlyProperties(payload, "actionId") && + TryString(payload, "actionId", 64, out var actionId) && + string.Equals(actionId, LegacyExpertWorkflowController.ActionId, StringComparison.Ordinal) + ? new LegacyActivateExpertActionIntent(actionId) + : null; + + private static LegacyUiIntent? ParseTradingHaltSearch(JsonElement payload) => + HasOnlyProperties(payload, "query") && + TryString( + payload, + "query", + LegacyTradingHaltSelectionService.MaximumQueryLength, + out var query) + ? new LegacySearchTradingHaltsIntent(query) + : null; + + private static LegacyUiIntent? ParseTradingHaltSelection(JsonElement payload) => + HasOnlyProperties(payload, "selectionId") && + TryString(payload, "selectionId", 128, out var selectionId) && + IsSafeIdentifier(selectionId) + ? new LegacySelectTradingHaltIntent(selectionId) + : null; + + private static LegacyUiIntent? ParseTradingHaltSort(JsonElement payload) => + HasOnlyProperties(payload) ? new LegacyToggleTradingHaltSortIntent() : null; + + private static LegacyUiIntent? ParseTradingHaltAction(JsonElement payload) + { + if (!HasOnlyProperties(payload, "actionId") || + !TryString(payload, "actionId", 64, out var actionId)) + { + return null; + } + + return actionId is LegacyTradingHaltWorkflowController.CurrentActionId or + LegacyTradingHaltWorkflowController.CandleActionId + ? new LegacyActivateTradingHaltActionIntent(actionId) + : null; + } + + private static LegacyUiIntent? ParseOperatorCatalogOpen(JsonElement payload) + { + if (!HasOnlyProperties(payload, "entity") || + !TryString(payload, "entity", 16, out var entityText)) + { + return null; + } + + var entity = entityText switch + { + "theme" => LegacyOperatorCatalogEntity.Theme, + "expert" => LegacyOperatorCatalogEntity.Expert, + _ => (LegacyOperatorCatalogEntity?)null + }; + return entity.HasValue ? new LegacyOpenOperatorCatalogIntent(entity.Value) : null; + } + + private static LegacyUiIntent? ParseOperatorCatalogEmpty( + JsonElement payload, + Func factory) => + HasOnlyProperties(payload) ? factory() : null; + + private static LegacyUiIntent? ParseOperatorCatalogSearch(JsonElement payload) + { + if (!HasOnlyProperties(payload, "query", "nxtSession") || + !TrySafeString( + payload, + "query", + 64, + allowEmpty: true, + allowUnderscore: true, + out var query) || + !TryString(payload, "nxtSession", 16, out var sessionText)) + { + return null; + } + + var session = sessionText switch + { + "preMarket" => ThemeSession.PreMarket, + "afterMarket" => ThemeSession.AfterMarket, + _ => (ThemeSession?)null + }; + return session.HasValue + ? new LegacySearchOperatorCatalogIntent(query, session.Value) + : null; + } + + private static LegacyUiIntent? ParseOperatorCatalogResult(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 128, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacySelectOperatorCatalogResultIntent(resultId) + : null; + + private static LegacyUiIntent? ParseOperatorCatalogThemeCreate(JsonElement payload) + { + if (!HasOnlyProperties(payload, "market") || + !TryString(payload, "market", 8, out var marketText)) + { + return null; + } + + var market = marketText switch + { + "krx" => ThemeMarket.Krx, + "nxt" => ThemeMarket.Nxt, + _ => (ThemeMarket?)null + }; + return market.HasValue + ? new LegacyBeginCreateThemeCatalogIntent(market.Value) + : null; + } + + private static LegacyUiIntent? ParseOperatorCatalogStockSearch(JsonElement payload) => + HasOnlyProperties(payload, "query") && + TrySafeString( + payload, + "query", + LegacyStockSearchService.MaximumQueryLength, + allowEmpty: false, + allowUnderscore: true, + out var query) + ? new LegacySearchOperatorCatalogStocksIntent(query) + : null; + + private static LegacyUiIntent? ParseOperatorCatalogStockSelection(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 128, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacySelectOperatorCatalogStockIntent(resultId) + : null; + + private static LegacyUiIntent? ParseOperatorCatalogStockAdd(JsonElement payload) + { + if (!HasOnlyProperties(payload, "buyAmount") || + !payload.TryGetProperty("buyAmount", out var amountElement)) + { + return null; + } + + if (amountElement.ValueKind == JsonValueKind.Null) + { + return new LegacyAddOperatorCatalogStockIntent(null); + } + + return amountElement.ValueKind == JsonValueKind.Number && + amountElement.TryGetDecimal(out var amount) && + decimal.Truncate(amount) == amount && + amount is > 0 and <= 9_007_199_254_740_991m + ? new LegacyAddOperatorCatalogStockIntent(amount) + : null; + } + + private static LegacyUiIntent? ParseOperatorCatalogStockActivation(JsonElement payload) + { + if (!HasOnlyProperties(payload, "resultId", "buyAmount") || + !TryString(payload, "resultId", 128, out var resultId) || + !IsSafeIdentifier(resultId) || + !payload.TryGetProperty("buyAmount", out var amountElement)) + { + return null; + } + + if (amountElement.ValueKind == JsonValueKind.Null) + { + return new LegacyActivateOperatorCatalogStockIntent(resultId, null); + } + + return amountElement.ValueKind == JsonValueKind.Number && + amountElement.TryGetDecimal(out var amount) && + decimal.Truncate(amount) == amount && + amount is > 0 and <= 9_007_199_254_740_991m + ? new LegacyActivateOperatorCatalogStockIntent(resultId, amount) + : null; + } + + private static LegacyUiIntent? ParseOperatorCatalogDraftMove(JsonElement payload) + { + if (!HasOnlyProperties(payload, "rowId", "direction") || + !TryString(payload, "rowId", 128, out var rowId) || + !IsSafeIdentifier(rowId) || + !TryString(payload, "direction", 8, out var directionText)) + { + return null; + } + + var direction = directionText switch + { + "up" => LegacyOperatorCatalogMoveDirection.Up, + "down" => LegacyOperatorCatalogMoveDirection.Down, + _ => (LegacyOperatorCatalogMoveDirection?)null + }; + return direction.HasValue + ? new LegacyMoveOperatorCatalogDraftRowIntent(rowId, direction.Value) + : null; + } + + private static LegacyUiIntent? ParseOperatorCatalogDraftRemove(JsonElement payload) => + HasOnlyProperties(payload, "rowId") && + TryString(payload, "rowId", 128, out var rowId) && + IsSafeIdentifier(rowId) + ? new LegacyRemoveOperatorCatalogDraftRowIntent(rowId) + : null; + + private static LegacyUiIntent? ParseOperatorCatalogDraftBuyAmount(JsonElement payload) => + HasOnlyProperties(payload, "rowId", "buyAmount") && + TryString(payload, "rowId", 128, out var rowId) && + IsSafeIdentifier(rowId) && + payload.TryGetProperty("buyAmount", out var amountElement) && + amountElement.ValueKind == JsonValueKind.Number && + amountElement.TryGetDecimal(out var amount) && + decimal.Truncate(amount) == amount && + amount is > 0 and <= 9_007_199_254_740_991m + ? new LegacySetOperatorCatalogDraftBuyAmountIntent(rowId, amount) + : null; + + private static LegacyUiIntent? ParseOperatorCatalogSave(JsonElement payload) => + HasOnlyProperties(payload, "name") && + TrySafeString( + payload, + "name", + 128, + allowEmpty: false, + allowUnderscore: true, + out var name) && + string.Equals(name, name.Trim(), StringComparison.Ordinal) + ? new LegacySaveOperatorCatalogIntent(name) + : null; + + private static LegacyUiIntent? ParseNamedPlaylistEmpty( + JsonElement payload, + Func factory) => + HasOnlyProperties(payload) ? factory() : null; + + private static LegacyUiIntent? ParseNamedPlaylistSelection(JsonElement payload) => + HasOnlyProperties(payload, "definitionId") && + TryString(payload, "definitionId", 128, out var definitionId) && + IsSafeIdentifier(definitionId) + ? new LegacySelectNamedPlaylistIntent(definitionId) + : null; + + private static LegacyUiIntent? ParseNamedPlaylistLoadById(JsonElement payload) => + HasOnlyProperties(payload, "definitionId") && + TryString(payload, "definitionId", 128, out var definitionId) && + IsSafeIdentifier(definitionId) + ? new LegacyLoadNamedPlaylistByIdIntent(definitionId) + : null; + + private static LegacyUiIntent? ParseNamedPlaylistSaveTo(JsonElement payload) => + HasOnlyProperties(payload, "definitionId") && + TryString(payload, "definitionId", 128, out var definitionId) && + IsSafeIdentifier(definitionId) + ? new LegacySaveCurrentNamedPlaylistToIntent(definitionId) + : null; + + private static LegacyUiIntent? ParseNamedPlaylistCreate(JsonElement payload) => + HasOnlyProperties(payload, "title") && + TrySafeString( + payload, + "title", + 128, + allowEmpty: false, + allowUnderscore: true, + out var title) && + string.Equals(title, title.Trim(), StringComparison.Ordinal) + ? new LegacyCreateNamedPlaylistIntent(title) + : null; + + private static LegacyUiIntent? ParseManualFinancialOpen(JsonElement payload) => + HasOnlyProperties(payload, "screen") && + TryString(payload, "screen", 32, out var screenText) && + TryManualFinancialScreen(screenText, out var screen) + ? new LegacyOpenManualFinancialIntent(screen) + : null; + + private static LegacyUiIntent? ParseManualFinancialEmpty( + JsonElement payload, + Func factory) => + HasOnlyProperties(payload) ? factory() : null; + + private static LegacyUiIntent? ParseManualFinancialSearch(JsonElement payload) => + HasOnlyProperties(payload, "query") && + TrySafeString(payload, "query", 64, allowEmpty: true, allowUnderscore: true, out var query) + ? new LegacySearchManualFinancialIntent(query) + : null; + + private static LegacyUiIntent? ParseManualFinancialFind(JsonElement payload) + { + if (!HasOnlyProperties(payload, "query", "direction", "generation") || + !TrySafeString( + payload, + "query", + 64, + allowEmpty: false, + allowUnderscore: true, + out var query) || + !TryString(payload, "direction", 8, out var directionText) || + !TryInt64(payload, "generation", 0, long.MaxValue, out var generation)) + { + return null; + } + + var direction = directionText switch + { + "up" => LegacyManualFinancialFindDirection.Up, + "down" => LegacyManualFinancialFindDirection.Down, + _ => (LegacyManualFinancialFindDirection?)null + }; + return direction.HasValue + ? new LegacyFindManualFinancialIntent(query, direction.Value, generation) + : null; + } + + private static LegacyUiIntent? ParseManualFinancialNameSort(JsonElement payload) => + HasOnlyProperties(payload, "generation") && + TryInt64(payload, "generation", 0, long.MaxValue, out var generation) + ? new LegacyToggleManualFinancialNameSortIntent(generation) + : null; + + private static LegacyUiIntent? ParseManualFinancialLoad(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 128, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacyLoadManualFinancialIntent(resultId) + : null; + + private static LegacyUiIntent? ParseManualFinancialResultActivation(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 128, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacyActivateManualFinancialResultIntent(resultId) + : null; + + private static LegacyUiIntent? ParseManualFinancialStockSearch(JsonElement payload) => + HasOnlyProperties(payload, "stockName") && + TrySafeString( + payload, + "stockName", + 64, + allowEmpty: false, + allowUnderscore: true, + out var stockName) + ? new LegacySearchManualFinancialStocksIntent(stockName) + : null; + + private static LegacyUiIntent? ParseManualFinancialStockSelection(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 128, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacySelectManualFinancialStockIntent(resultId) + : null; + + private static LegacyUiIntent? ParseManualFinancialSave(JsonElement payload) + { + if (!HasOnlyProperties(payload, "record") || + !payload.TryGetProperty("record", out var recordElement) || + recordElement.ValueKind != JsonValueKind.Object) + { + return null; + } + + var record = ParseManualFinancialRecord(recordElement); + return record is null ? null : new LegacySaveManualFinancialIntent(record); + } + + private static LegacyUiIntent? ParseManualFinancialAction(JsonElement payload) => + HasOnlyProperties(payload, "actionId") && + TryString(payload, "actionId", 64, out var actionId) && + LegacyManualFinancialWorkflow.Screens.Any(definition => + string.Equals(definition.ActionId, actionId, StringComparison.Ordinal)) + ? new LegacyActivateManualFinancialActionIntent(actionId) + : null; + + private static LegacyUiIntent? ParseManualListOpen(JsonElement payload) + { + if (!HasOnlyProperties(payload, "screen") || + !TryString(payload, "screen", 24, out var screen)) + { + return null; + } + + return screen switch + { + "individual" => new LegacyOpenManualListIntent( + LegacyManualListScreen.NetSell, + S5025ManualAudience.Individual), + "foreign" => new LegacyOpenManualListIntent( + LegacyManualListScreen.NetSell, + S5025ManualAudience.Foreign), + "institution" => new LegacyOpenManualListIntent( + LegacyManualListScreen.NetSell, + S5025ManualAudience.Institution), + "vi" => new LegacyOpenManualListIntent( + LegacyManualListScreen.Vi, + S5025ManualAudience.Individual), + _ => null + }; + } + + private static LegacyUiIntent? ParseManualListEmpty( + JsonElement payload, + Func factory) => + HasOnlyProperties(payload) ? factory() : null; + + private static LegacyUiIntent? ParseManualNetSellCell(JsonElement payload) + { + if (!HasOnlyProperties(payload, "rowId", "field", "value") || + !TryString(payload, "rowId", 64, out var rowId) || + !IsSafeIdentifier(rowId) || + !TryString(payload, "field", 24, out var fieldText) || + !TrySafeString( + payload, + "value", + LegacyManualListsWorkflow.MaximumNetSellValueLength, + allowEmpty: true, + allowUnderscore: true, + out var value) || + value.Contains('^')) + { + return null; + } + + var field = fieldText switch + { + "leftName" => LegacyManualNetSellField.LeftName, + "leftAmount" => LegacyManualNetSellField.LeftAmount, + "rightName" => LegacyManualNetSellField.RightName, + "rightAmount" => LegacyManualNetSellField.RightAmount, + _ => (LegacyManualNetSellField?)null + }; + return field.HasValue + ? new LegacySetManualNetSellCellIntent(rowId, field.Value, value) + : null; + } + + private static LegacyUiIntent? ParseManualViSearch(JsonElement payload) => + HasOnlyProperties(payload, "query") && + TrySafeString( + payload, + "query", + LegacyManualListsWorkflow.MaximumSearchLength, + allowEmpty: false, + allowUnderscore: true, + out var query) + ? new LegacySearchManualViIntent(query) + : null; + + private static LegacyUiIntent? ParseManualViResult(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 64, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacyAddManualViResultIntent(resultId) + : null; + + private static LegacyUiIntent? ParseManualViSelection(JsonElement payload) => + HasOnlyProperties(payload, "resultId") && + TryString(payload, "resultId", 64, out var resultId) && + IsSafeIdentifier(resultId) + ? new LegacySelectManualViResultIntent(resultId) + : null; + + private static LegacyUiIntent? ParseManualViMove(JsonElement payload) + { + if (!HasOnlyProperties(payload, "itemId", "direction") || + !TryString(payload, "itemId", 64, out var itemId) || + !IsSafeIdentifier(itemId) || + !TryString(payload, "direction", 8, out var directionText)) + { + return null; + } + + var direction = directionText switch + { + "up" => LegacyManualListMoveDirection.Up, + "down" => LegacyManualListMoveDirection.Down, + _ => (LegacyManualListMoveDirection?)null + }; + return direction.HasValue + ? new LegacyMoveManualViItemIntent(itemId, direction.Value) + : null; + } + + private static LegacyUiIntent? ParseManualViDelete(JsonElement payload) => + HasOnlyProperties(payload, "itemId") && + TryString(payload, "itemId", 64, out var itemId) && + IsSafeIdentifier(itemId) + ? new LegacyDeleteManualViItemIntent(itemId) + : null; + + private static ManualFinancialRecord? ParseManualFinancialRecord(JsonElement element) + { + if (!element.TryGetProperty("screen", out var screenElement) || + screenElement.ValueKind != JsonValueKind.String || + !TryManualFinancialScreen(screenElement.GetString(), out var screen) || + !TrySafeString( + element, + "stockName", + 200, + allowEmpty: false, + allowUnderscore: true, + out var stockName)) + { + return null; + } + + var identity = new ManualFinancialIdentity(screen, stockName); + return screen switch + { + ManualFinancialScreenKind.RevenueComposition => + ParseManualRevenue(element, identity), + ManualFinancialScreenKind.GrowthMetrics => + ParseManualGrowth(element, identity), + ManualFinancialScreenKind.Sales => + ParseManualQuarters(element, identity, operatingProfit: false), + ManualFinancialScreenKind.OperatingProfit => + ParseManualQuarters(element, identity, operatingProfit: true), + _ => null + }; + } + + private static ManualRevenueCompositionRecord? ParseManualRevenue( + JsonElement element, + ManualFinancialIdentity identity) + { + if (!HasOnlyProperties(element, "screen", "stockName", "baseDate", "slices") || + !TrySafeString( + element, + "baseDate", + 200, + allowEmpty: true, + allowUnderscore: true, + out var baseDate) || + !element.TryGetProperty("slices", out var slicesElement) || + slicesElement.ValueKind != JsonValueKind.Array || + slicesElement.GetArrayLength() != 5) + { + return null; + } + + var slices = new ManualRevenueSlice?[5]; + var index = 0; + foreach (var item in slicesElement.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Null) + { + slices[index++] = null; + continue; + } + + if (item.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(item, "label", "percentageText") || + !TrySafeString( + item, + "label", + 200, + allowEmpty: false, + allowUnderscore: false, + out var label) || + !TrySafeString( + item, + "percentageText", + 64, + allowEmpty: false, + allowUnderscore: false, + out var percentageText) || + !TryManualDoubleText(percentageText, out var percentage)) + { + return null; + } + + slices[index++] = new ManualRevenueSlice(label, percentageText, percentage); + } + + return new ManualRevenueCompositionRecord(identity, baseDate, slices); + } + + private static ManualGrowthMetricsRecord? ParseManualGrowth( + JsonElement element, + ManualFinancialIdentity identity) + { + if (!HasOnlyProperties( + element, + "screen", + "stockName", + "salesGrowth", + "operatingProfitGrowth", + "netAssetGrowth", + "netIncomeGrowth", + "periods") || + !TryGrowthSeries(element, "salesGrowth", out var salesGrowth) || + !TryGrowthSeries( + element, + "operatingProfitGrowth", + out var operatingProfitGrowth) || + !TryGrowthSeries(element, "netAssetGrowth", out var netAssetGrowth) || + !TryGrowthSeries(element, "netIncomeGrowth", out var netIncomeGrowth) || + !TryStringArray( + element, + "periods", + 4, + allowEmpty: true, + allowUnderscore: false, + out var periods)) + { + return null; + } + + return new ManualGrowthMetricsRecord( + identity, + salesGrowth, + operatingProfitGrowth, + netAssetGrowth, + netIncomeGrowth, + new ManualGrowthPeriodLabels(periods[0], periods[1], periods[2], periods[3])); + } + + private static ManualFinancialRecord? ParseManualQuarters( + JsonElement element, + ManualFinancialIdentity identity, + bool operatingProfit) + { + if (!HasOnlyProperties(element, "screen", "stockName", "quarters") || + !element.TryGetProperty("quarters", out var quartersElement) || + quartersElement.ValueKind != JsonValueKind.Array || + quartersElement.GetArrayLength() != 6) + { + return null; + } + + var quarters = new ManualQuarterValue[6]; + var index = 0; + foreach (var item in quartersElement.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.Object || + !HasOnlyProperties(item, "quarter", "value") || + !TrySafeString( + item, + "quarter", + 200, + allowEmpty: false, + allowUnderscore: false, + out var quarter) || + !item.TryGetProperty("value", out var valueElement) || + valueElement.ValueKind != JsonValueKind.Number || + !valueElement.TryGetInt32(out var value)) + { + return null; + } + + quarters[index++] = new ManualQuarterValue(quarter, value); + } + + return operatingProfit + ? new ManualOperatingProfitRecord(identity, quarters) + : new ManualSalesRecord(identity, quarters); + } + + private static bool TryGrowthSeries( + JsonElement element, + string propertyName, + out ManualGrowthSeriesValues values) + { + values = new ManualGrowthSeriesValues(null, null, null, null); + if (!element.TryGetProperty(propertyName, out var array) || + array.ValueKind != JsonValueKind.Array || array.GetArrayLength() != 4) + { + return false; + } + + var result = new double?[4]; + var index = 0; + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Null) + { + result[index++] = null; + continue; + } + + if (item.ValueKind != JsonValueKind.Number || + !item.TryGetDouble(out var number) || !double.IsFinite(number) || + Math.Abs(number) > 1_000_000_000_000d) + { + return false; + } + + result[index++] = number; + } + + values = new ManualGrowthSeriesValues(result[0], result[1], result[2], result[3]); + return true; + } + + private static bool TryStringArray( + JsonElement element, + string propertyName, + int expectedCount, + bool allowEmpty, + bool allowUnderscore, + out string[] values) + { + values = []; + if (!element.TryGetProperty(propertyName, out var array) || + array.ValueKind != JsonValueKind.Array || + array.GetArrayLength() != expectedCount) + { + return false; + } + + var result = new string[expectedCount]; + var index = 0; + foreach (var item in array.EnumerateArray()) + { + if (item.ValueKind != JsonValueKind.String || + !IsSafeBridgeText( + item.GetString() ?? string.Empty, + 200, + allowEmpty, + allowUnderscore)) + { + return false; + } + + result[index++] = item.GetString()!; + } + + values = result; + return true; + } + + private static bool TryManualDoubleText(string value, out double number) + { + if (string.Equals(value, "-", StringComparison.Ordinal)) + { + number = 0; + return true; + } + + return double.TryParse( + value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out number) && + double.IsFinite(number) && Math.Abs(number) <= 1_000_000_000_000d; + } + + private static bool TryManualFinancialScreen( + string? value, + out ManualFinancialScreenKind screen) + { + var parsed = value switch + { + "revenue-composition" => ManualFinancialScreenKind.RevenueComposition, + "growth-metrics" => ManualFinancialScreenKind.GrowthMetrics, + "sales" => ManualFinancialScreenKind.Sales, + "operating-profit" => ManualFinancialScreenKind.OperatingProfit, + _ => (ManualFinancialScreenKind?)null + }; + screen = parsed.GetValueOrDefault(); + return parsed.HasValue; + } + + private static string ManualFinancialScreenId(ManualFinancialScreenKind screen) => screen switch + { + ManualFinancialScreenKind.RevenueComposition => "revenue-composition", + ManualFinancialScreenKind.GrowthMetrics => "growth-metrics", + ManualFinancialScreenKind.Sales => "sales", + ManualFinancialScreenKind.OperatingProfit => "operating-profit", + _ => throw new ArgumentOutOfRangeException(nameof(screen)) + }; + + private static object? ProjectManualFinancialRecord(ManualFinancialRecord? record) => + record switch + { + ManualRevenueCompositionRecord revenue => new + { + screen = ManualFinancialScreenId(revenue.Identity.Screen), + stockName = revenue.Identity.StockName, + revenue.BaseDate, + slices = revenue.Slices.Select(slice => slice is null ? null : new + { + slice.Label, + slice.PercentageText + }) + }, + ManualGrowthMetricsRecord growth => new + { + screen = ManualFinancialScreenId(growth.Identity.Screen), + stockName = growth.Identity.StockName, + salesGrowth = growth.SalesGrowth.ToArray(), + operatingProfitGrowth = growth.OperatingProfitGrowth.ToArray(), + netAssetGrowth = growth.NetAssetGrowth.ToArray(), + netIncomeGrowth = growth.NetIncomeGrowth.ToArray(), + periods = growth.Periods.ToArray() + }, + ManualSalesRecord sales => new + { + screen = ManualFinancialScreenId(sales.Identity.Screen), + stockName = sales.Identity.StockName, + sales.Quarters + }, + ManualOperatingProfitRecord profit => new + { + screen = ManualFinancialScreenId(profit.Identity.Screen), + stockName = profit.Identity.StockName, + profit.Quarters + }, + null => null, + _ => throw new ArgumentOutOfRangeException(nameof(record)) + }; + + private static bool TrySafeString( + JsonElement element, + string propertyName, + int maximumLength, + bool allowEmpty, + bool allowUnderscore, + out string value) + { + value = string.Empty; + return TryString(element, propertyName, maximumLength, out value) && + IsSafeBridgeText(value, maximumLength, allowEmpty, allowUnderscore); + } + + private static bool IsSafeBridgeText( + string value, + int maximumLength, + bool allowEmpty, + bool allowUnderscore) => + value.Length <= maximumLength && + (allowEmpty || value.Length > 0) && + (allowUnderscore || !value.Contains('_')) && + value.All(static character => + !char.IsControl(character) && !char.IsSurrogate(character) && + character != '\uFFFD'); + private static LegacyUiIntent? ParseDismiss(JsonElement payload) => HasOnlyProperties(payload) ? new LegacyDismissDialogIntent() : null; @@ -251,6 +2313,40 @@ public static class LegacyBridgeProtocol return true; } + private static bool TryTabId( + JsonElement element, + string name, + out LegacyOperatorTabId tabId) + { + tabId = default; + if (!TryString(element, name, 32, out var value)) + { + return false; + } + + var parsed = value switch + { + "overseas" => LegacyOperatorTabId.Overseas, + "exchange" => LegacyOperatorTabId.Exchange, + "index" => LegacyOperatorTabId.Index, + "kospiIndustry" => LegacyOperatorTabId.KospiIndustry, + "kosdaqIndustry" => LegacyOperatorTabId.KosdaqIndustry, + "comparison" => LegacyOperatorTabId.Comparison, + "theme" => LegacyOperatorTabId.Theme, + "overseasStocks" => LegacyOperatorTabId.OverseasStocks, + "expert" => LegacyOperatorTabId.Expert, + "tradingHalt" => LegacyOperatorTabId.TradingHalt, + _ => (LegacyOperatorTabId?)null + }; + if (!parsed.HasValue) + { + return false; + } + + tabId = parsed.Value; + return true; + } + private static bool TryInt32( JsonElement element, string name, @@ -290,6 +2386,10 @@ public static class LegacyBridgeProtocol char.IsAsciiLetterOrDigit(character) || character is '-' or '_'); } + private static bool IsSafeOpaqueActionId(string value) => + value.Length is > 0 and <= 64 && value.All(character => + char.IsLetterOrDigit(character) || character is '-' or '_'); + private static JsonSerializerOptions CreateSerializerOptions() { var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs new file mode 100644 index 0000000..d7f463d --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyBridge/LegacyPlayoutUiIntents.cs @@ -0,0 +1,69 @@ +using MBN_STOCK_WEBVIEW.LegacyApplication; +using System.Text.Json; + +namespace MBN_STOCK_WEBVIEW.LegacyBridge; + +public sealed record LegacyExecutePlayoutIntent(LegacyOperatorPlayoutCommand Command) + : LegacyUiIntent; + +public sealed record LegacySetFadeDurationIntent(int Duration) + : LegacyUiIntent; + +public sealed record LegacyToggleBackgroundIntent : LegacyUiIntent; + +public sealed record LegacyChooseBackgroundIntent : LegacyUiIntent; + +internal static class LegacyPlayoutUiIntentParser +{ + public static LegacyUiIntent? Parse(string type, JsonElement payload) => type switch + { + "prepare-playout" => ParseEmpty( + payload, + static () => new LegacyExecutePlayoutIntent( + LegacyOperatorPlayoutCommand.Prepare)), + "take-in" => ParseEmpty( + payload, + static () => new LegacyExecutePlayoutIntent( + LegacyOperatorPlayoutCommand.TakeIn)), + "next-playout" => ParseEmpty( + payload, + static () => new LegacyExecutePlayoutIntent( + LegacyOperatorPlayoutCommand.Next)), + "take-out" => ParseEmpty( + payload, + static () => new LegacyExecutePlayoutIntent( + LegacyOperatorPlayoutCommand.TakeOut)), + "set-fade-duration" => ParseFadeDuration(payload), + "toggle-background" => ParseEmpty( + payload, + static () => new LegacyToggleBackgroundIntent()), + "choose-background" => ParseEmpty( + payload, + static () => new LegacyChooseBackgroundIntent()), + _ => null + }; + + private static LegacyUiIntent? ParseFadeDuration(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object || + payload.GetRawText().Length > 64 || + payload.EnumerateObject().Count() != 1 || + !payload.TryGetProperty("duration", out var durationElement) || + durationElement.ValueKind != JsonValueKind.Number || + !durationElement.TryGetInt32(out var duration) || + duration is < 0 or > 60) + { + return null; + } + + return new LegacySetFadeDurationIntent(duration); + } + + private static LegacyUiIntent? ParseEmpty( + JsonElement payload, + Func factory) => + payload.ValueKind == JsonValueKind.Object && + !payload.EnumerateObject().Any() + ? factory() + : null; +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj index b9750ee..a3cacfe 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MBN_STOCK_WEBVIEW.LegacyParityApp.csproj @@ -26,6 +26,8 @@ + diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs new file mode 100644 index 0000000..0262a84 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.CloseConfirmation.cs @@ -0,0 +1,130 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml.Controls; +using WinRT.Interop; + +namespace MBN_STOCK_WEBVIEW.LegacyParityApp; + +public sealed partial class MainWindow +{ + private AppWindow? _closeConfirmationAppWindow; + private int _closeConfirmationPending; + private int _closeConfirmed; + + private void EnsureCloseConfirmationAttached() + { + if (_closeConfirmationAppWindow is not null) + { + return; + } + + try + { + var windowHandle = WindowNative.GetWindowHandle(this); + var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle); + var appWindow = AppWindow.GetFromWindowId(windowId); + appWindow.Closing += OnAppWindowClosing; + _closeConfirmationAppWindow = appWindow; + } + catch + { + // The constructor can run before the native window exists. Root.Loaded + // retries the attachment after WinUI has created it. + } + } + + private async void OnAppWindowClosing(AppWindow sender, AppWindowClosingEventArgs args) + { + if (Volatile.Read(ref _closeConfirmed) != 0) + { + return; + } + + // AppWindow.Closing has no deferral. Cancel synchronously and close a second + // time only after the operator explicitly confirms. The default is Cancel, + // matching MainForm_FormClosing's default No choice. + args.Cancel = true; + if (Interlocked.CompareExchange(ref _closeConfirmationPending, 1, 0) != 0) + { + return; + } + + try + { + if (Root.XamlRoot is null) + { + return; + } + + var dialog = new ContentDialog + { + XamlRoot = Root.XamlRoot, + Title = "프로그램 종료", + Content = BuildCloseConfirmationMessage(), + PrimaryButtonText = "종료", + CloseButtonText = "취소", + DefaultButton = ContentDialogButton.Close + }; + + var result = await dialog.ShowAsync(); + if (result != ContentDialogResult.Primary) + { + return; + } + + Interlocked.Exchange(ref _closeConfirmed, 1); + Close(); + } + catch + { + // If the confirmation surface cannot be shown, keep the application open. + } + finally + { + Interlocked.Exchange(ref _closeConfirmationPending, 0); + } + } + + private string BuildCloseConfirmationMessage() + { + var status = _playoutEngine?.Status; + var workflow = _playoutWorkflow?.State; + var hasPreparedScene = !string.IsNullOrWhiteSpace(status?.PreparedSceneName) || + !string.IsNullOrWhiteSpace(workflow?.PreparedCutCode); + var hasOnAirScene = !string.IsNullOrWhiteSpace(status?.OnAirSceneName) || + !string.IsNullOrWhiteSpace(workflow?.OnAirCutCode); + var commandPending = Volatile.Read(ref _playoutBusy) != 0 || + status?.IsPlayCompletionPending == true; + var outcomeUnknown = Volatile.Read(ref _playoutQuarantined) != 0 || + status?.State == PlayoutConnectionState.OutcomeUnknown; + + if (outcomeUnknown) + { + return "송출 결과를 확정할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 종료하세요. " + + "종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?"; + } + + if (commandPending) + { + return "송출 명령 또는 callback 처리가 진행 중입니다. 결과를 확인한 뒤 종료하는 것이 안전합니다. " + + "그래도 종료하시겠습니까?"; + } + + if (hasOnAirScene || hasPreparedScene) + { + return "PREPARED 또는 PROGRAM 장면이 남아 있습니다. 필요한 경우 먼저 TAKE OUT을 실행하고 " + + "PGM을 확인하세요. 종료 과정에서는 TAKE OUT을 자동 실행하지 않습니다. 그래도 종료하시겠습니까?"; + } + + return "프로그램을 종료하시겠습니까?"; + } + + private void DetachCloseConfirmation() + { + var appWindow = Interlocked.Exchange(ref _closeConfirmationAppWindow, null); + if (appWindow is not null) + { + appWindow.Closing -= OnAppWindowClosing; + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.ManualLists.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.ManualLists.cs new file mode 100644 index 0000000..9566d54 --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.ManualLists.cs @@ -0,0 +1,232 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.LegacyApplication; + +namespace MBN_STOCK_WEBVIEW.LegacyParityApp; + +public sealed partial class MainWindow +{ + private const string OperatorDataDirectoryEnvironmentVariable = + "MBN_STOCK_OPERATOR_DATA_DIRECTORY"; + + private S5025TrustedManualFileStore? _manualNetSellStore; + private ViTrustedManualFileStore? _manualViStore; + private LegacyManualOperatorDataImporter? _manualDataImporter; + + private LegacyManualListsWorkflow CreateManualListsWorkflow( + ILegacyStockLookup stockLookup) + { + ILegacyManualListStore store; + try + { + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable( + OperatorDataDirectoryEnvironmentVariable))) + { + store = new UnavailableManualListStore( + "UNSAFE_CONFIGURATION", + "외부 경로 설정은 사용하지 않습니다. 앱 전용 로컬 저장소만 사용할 수 있습니다."); + return new LegacyManualListsWorkflow(store, stockLookup); + } + + var directory = TrustedManualDirectory.PreparePrivateWritableDirectory( + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MBN_STOCK_WEBVIEW", + "OperatorData")); + var inspection = LegacyManualOperatorDataRuntimeGuard.Inspect(directory); + if (!inspection.IsReady) + { + var interrupted = inspection.State == + LegacyManualOperatorDataRuntimeState.InterruptedImport; + store = new UnavailableManualListStore( + interrupted ? "IMPORT_INTERRUPTED" : "IMPORTED_STATE_INVALID", + interrupted + ? "이전 원본 데이터 가져오기가 중단되어 수동 파일을 열지 않았습니다." + : "수동 데이터 가져오기 표식과 파일 상태가 일치하지 않습니다."); + return new LegacyManualListsWorkflow(store, stockLookup); + } + + _manualNetSellStore = new S5025TrustedManualFileStore(directory); + _manualViStore = new ViTrustedManualFileStore(directory); + _manualDataImporter = new LegacyManualOperatorDataImporter( + directory, + new FixedLegacyManualOperatorDataSource()); + store = new TrustedManualListStore( + _manualNetSellStore, + _manualViStore, + _manualDataImporter); + } + catch + { + _manualDataImporter?.Dispose(); + _manualViStore?.Dispose(); + _manualNetSellStore?.Dispose(); + _manualDataImporter = null; + _manualViStore = null; + _manualNetSellStore = null; + store = new UnavailableManualListStore( + "INITIALIZATION_FAILED", + "수동 순매도/VI 로컬 저장소를 초기화하지 못했습니다."); + } + + return new LegacyManualListsWorkflow(store, stockLookup); + } + + private void ShutdownManualListsRuntime() + { + var importer = Interlocked.Exchange(ref _manualDataImporter, null); + var viStore = Interlocked.Exchange(ref _manualViStore, null); + var netStore = Interlocked.Exchange(ref _manualNetSellStore, null); + if (importer is null && viStore is null && netStore is null) + { + return; + } + + _ = Task.Run(async () => + { + await _intentGate.WaitAsync().ConfigureAwait(false); + try + { + importer?.Dispose(); + viStore?.Dispose(); + netStore?.Dispose(); + } + finally + { + _intentGate.Release(); + } + }); + } + + private sealed class TrustedManualListStore : ILegacyManualListStore + { + private readonly S5025TrustedManualFileStore _netStore; + private readonly ViTrustedManualFileStore _viStore; + private readonly LegacyManualOperatorDataImporter _importer; + + public TrustedManualListStore( + S5025TrustedManualFileStore netStore, + ViTrustedManualFileStore viStore, + LegacyManualOperatorDataImporter importer) + { + _netStore = netStore; + _viStore = viStore; + _importer = importer; + } + + public LegacyManualListStoreAvailability Availability { get; } = + new(true, "READY", null); + + public Task> ReadNetSellAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) => + _netStore.ReadAsync(audience, cancellationToken); + + public Task WriteNetSellAsync( + S5025ManualAudience audience, + IReadOnlyList rows, + CancellationToken cancellationToken = default) => + _netStore.WriteAsync(audience, rows, cancellationToken); + + public async Task ReadViAsync( + CancellationToken cancellationToken = default) + { + var snapshot = await _viStore.ReadSnapshotAsync(cancellationToken) + .ConfigureAwait(false); + return Map(snapshot); + } + + public async Task WriteViAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default) + { + var result = await _viStore.WriteAsync( + items.Select(item => new ViTrustedManualItem(item.Code, item.Name)).ToArray(), + cancellationToken).ConfigureAwait(false); + return new LegacyManualViWriteReceipt(Map(result.Snapshot), result.Persisted); + } + + public async Task ImportAsync( + CancellationToken cancellationToken = default) + { + try + { + var result = await _importer.ImportAsync(cancellationToken) + .ConfigureAwait(false); + return new LegacyManualImportReceipt( + result.MarkerVersion, + result.SourceSha256, + result.ImportedAt, + result.NetSellRowCount, + result.ViItemCount); + } + catch (LegacyManualOperatorImportException exception) + { + throw new LegacyManualImportStoreException( + ImportFailureCode(exception.Failure), + exception.OutcomeUnknown); + } + } + + private static LegacyManualViStoreSnapshot Map(ViTrustedManualSnapshot snapshot) => + new( + Array.AsReadOnly(snapshot.Items.Select(item => + new LegacyManualViStoredItem(item.Code, item.Name)).ToArray()), + snapshot.RowVersion); + + private static string ImportFailureCode(LegacyManualOperatorImportFailure failure) => + failure switch + { + LegacyManualOperatorImportFailure.SourceUnavailable => "SOURCE_UNAVAILABLE", + LegacyManualOperatorImportFailure.SourceInvalid => "SOURCE_INVALID", + LegacyManualOperatorImportFailure.AlreadyImported => "ALREADY_IMPORTED", + LegacyManualOperatorImportFailure.DestinationNotEmpty => + "DESTINATION_NOT_EMPTY", + LegacyManualOperatorImportFailure.InterruptedImport => "INTERRUPTED_IMPORT", + LegacyManualOperatorImportFailure.ImportFailed => "IMPORT_FAILED", + LegacyManualOperatorImportFailure.RollbackFailed => "ROLLBACK_FAILED", + _ => "IMPORT_FAILED" + }; + } + + private sealed class UnavailableManualListStore : ILegacyManualListStore + { + private readonly string _message; + + public UnavailableManualListStore(string code, string message) + { + _message = message; + Availability = new LegacyManualListStoreAvailability(false, code, message); + } + + public LegacyManualListStoreAvailability Availability { get; } + + public Task> ReadNetSellAsync( + S5025ManualAudience audience, + CancellationToken cancellationToken = default) => + Task.FromException>( + new InvalidOperationException(_message)); + + public Task WriteNetSellAsync( + S5025ManualAudience audience, + IReadOnlyList rows, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task ReadViAsync( + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task WriteViAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task ImportAsync( + CancellationToken cancellationToken = default) => + Task.FromException( + new LegacyManualImportStoreException("UNAVAILABLE", false)); + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs new file mode 100644 index 0000000..89c92ac --- /dev/null +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.Playout.cs @@ -0,0 +1,836 @@ +using MBN_STOCK_WEBVIEW.Core.Playout; +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MBN_STOCK_WEBVIEW.LegacyApplication; +using MBN_STOCK_WEBVIEW.LegacyBridge; +using MBN_STOCK_WEBVIEW.Playout; +using MBN_STOCK_WEBVIEW.Playout.Configuration; +using Microsoft.UI.Xaml; +using Windows.Storage.Pickers; +using WinRT.Interop; + +namespace MBN_STOCK_WEBVIEW.LegacyParityApp; + +public sealed partial class MainWindow +{ + private readonly SemaphoreSlim _playoutCommandGate = new(1, 1); + private IPlayoutEngine? _playoutEngine; + private LegacyPlayoutWorkflow? _playoutWorkflow; + private PlayoutOptions? _playoutOptions; + private MutableLegacySceneCueCompositionOptionsSource? _compositionOptions; + private string? _playoutInitializationError; + private string? _playoutInitializationWarning; + private CancellationTokenSource? _refreshCancellation; + private Task? _refreshTask; + private int _playoutBusy; + private int _playoutQuarantined; + private int _playoutShutdownStarted; + private int _operatorMutationQuarantined; + private int _refreshCompletedCount; + private bool _refreshLimitReached; + private string _refreshMessage = string.Empty; + + private void InitializePlayoutRuntime() + { + try + { + var explicitLocalConfigurationExists = File.Exists( + PlayoutOptionsLoader.DefaultPath); + _playoutOptions = PlayoutOptionsLoader.Load(); + var startupComposition = LegacyParityStartupCompositionResolver.Resolve( + _playoutOptions, + explicitLocalConfigurationExists); + var initialComposition = startupComposition.Composition; + _playoutInitializationWarning = startupComposition.HasWarning + ? startupComposition.WarningMessage + : null; + _compositionOptions = new MutableLegacySceneCueCompositionOptionsSource( + initialComposition); + _playoutEngine = PlayoutEngineFactory.Create(_playoutOptions); + _playoutEngine.StatusChanged += OnPlayoutStatusChanged; + if (_databaseRuntime is not null) + { + _playoutWorkflow = global::MBN_STOCK_WEBVIEW.LegacySceneRuntimeFactory.Create( + _playoutEngine, + _databaseRuntime.Executor, + compositionOptionsSource: _compositionOptions, + trustedManualSource: _manualNetSellStore, + trustedViSource: _manualViStore); + } + } + catch (Exception exception) + { + _playoutInitializationWarning = null; + _playoutInitializationError = exception is PlayoutConfigurationException + ? exception.Message + : "Tornado 송출 어댑터를 초기화하지 못했습니다."; + } + } + + private async Task ConnectPlayoutAsync(CancellationToken cancellationToken) + { + var engine = _playoutEngine; + if (engine is null) + { + QueueOperatorState(); + return; + } + + try + { + var result = await engine.ConnectAsync(cancellationToken).ConfigureAwait(false); + if (!result.IsSuccess) + { + _playoutInitializationWarning = null; + _playoutInitializationError = result.Message; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch + { + _playoutInitializationWarning = null; + _playoutInitializationError = "Tornado 송출 엔진에 연결하지 못했습니다."; + } + + QueueOperatorState(); + } + + private void OnPlayoutStatusChanged(object? sender, PlayoutStatusChangedEventArgs args) + { + _playoutWorkflow?.ObserveEngineStatus(args.Current); + if (string.IsNullOrWhiteSpace(args.Current.PreparedSceneName) && + string.IsNullOrWhiteSpace(args.Current.OnAirSceneName)) + { + StopRefreshLoop(); + } + + QueueOperatorState(); + } + + private async Task ExecuteOperatorPlayoutAsync( + LegacyOperatorPlayoutCommand command, + CancellationToken cancellationToken) + { + var engine = _playoutEngine; + var workflow = _playoutWorkflow; + if (engine is null || workflow is null) + { + return _controller.ReportError( + _playoutInitializationError ?? "Tornado 송출 기능을 사용할 수 없습니다."); + } + + if (Volatile.Read(ref _playoutQuarantined) != 0 || + engine.Status.State == PlayoutConnectionState.OutcomeUnknown) + { + return _controller.ReportError( + "송출 결과를 확인할 수 없는 상태입니다. PGM과 Network Monitoring을 확인한 뒤 앱을 다시 시작하세요."); + } + + if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return _controller.ReportError("다른 송출 명령을 처리하고 있습니다."); + } + + try + { + Interlocked.Exchange(ref _playoutBusy, 1); + StopRefreshLoop(); + var nextKindBefore = workflow.State.NextKind; + if (command == LegacyOperatorPlayoutCommand.Next && + engine.Status.IsPlayCompletionPending) + { + return _controller.ReportError( + "Tornado 재생 완료 이벤트를 기다리는 중입니다. NEXT를 반복하지 마세요."); + } + + PlayoutResult result; + switch (command) + { + case LegacyOperatorPlayoutCommand.Prepare: + var request = _controller.CreatePlayoutRequest(); + result = await workflow.PrepareAsync( + request.Playlist, + request.SelectedIndexZeroBased, + cancellationToken).ConfigureAwait(false); + break; + case LegacyOperatorPlayoutCommand.TakeIn: + // MainForm.btnTake_Click performed ONAirMode (load/prepare) and + // Play in one visible F8 action. Keep the explicit PREPARE control + // for engineering checks, but preserve the original one-click path + // whenever no scene is already prepared. + if (string.IsNullOrWhiteSpace(workflow.State.PreparedCutCode)) + { + var takeInRequest = _controller.CreatePlayoutRequest(); + result = await workflow.PrepareAndTakeInAsync( + takeInRequest.Playlist, + takeInRequest.SelectedIndexZeroBased, + cancellationToken).ConfigureAwait(false); + } + else + { + result = await workflow.TakeInAsync(cancellationToken).ConfigureAwait(false); + } + break; + case LegacyOperatorPlayoutCommand.Next: + result = await workflow.NextAsync(cancellationToken).ConfigureAwait(false); + break; + case LegacyOperatorPlayoutCommand.TakeOut: + result = await workflow.TakeOutAsync( + PlayoutTakeOutScope.All, + cancellationToken).ConfigureAwait(false); + break; + default: + throw new ArgumentOutOfRangeException(nameof(command)); + } + + if (result.Code is PlayoutResultCode.OutcomeUnknown or PlayoutResultCode.TimedOut) + { + await QuarantinePlayoutAsync().ConfigureAwait(false); + return _controller.ReportError( + "송출 명령 결과가 불명확합니다. 명령을 반복하지 말고 PGM과 Network Monitoring을 확인하세요."); + } + + if (!result.IsSuccess) + { + return _controller.ReportError(result.Message); + } + + if (command == LegacyOperatorPlayoutCommand.TakeIn || + command == LegacyOperatorPlayoutCommand.Next && + nextKindBefore == LegacyWorkflowNextKind.PlaylistNext) + { + StartRefreshLoop(workflow); + } + + return _controller.ReportInformation(result.Message); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return _controller.ReportError("송출 요청이 취소되었습니다."); + } + catch (DatabaseOperationException exception) + { + return _controller.ReportError(exception.Message); + } + catch (Exception exception) when ( + exception is LegacySceneDataException or ArgumentException or InvalidOperationException) + { + return _controller.ReportError(exception.Message); + } + catch + { + await QuarantinePlayoutAsync().ConfigureAwait(false); + return _controller.ReportError( + "송출 엔진 명령의 결과를 확인할 수 없습니다. 명령을 반복하지 마세요."); + } + finally + { + Interlocked.Exchange(ref _playoutBusy, 0); + _playoutCommandGate.Release(); + QueueOperatorState(); + } + } + + private async Task SetFadeDurationAsync( + int fadeDuration, + CancellationToken cancellationToken) + { + if (fadeDuration is < 0 or > 60) + { + return _controller.ReportError("Fade 값이 올바르지 않습니다."); + } + + if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return _controller.ReportError("송출 명령 처리 중에는 Fade를 변경할 수 없습니다."); + } + + try + { + if (!CanChangeComposition()) + { + return _controller.ReportError("Fade는 IDLE 상태에서만 변경할 수 있습니다."); + } + + var source = _compositionOptions!; + source.Update(source.Current with { FadeDuration = fadeDuration }); + return _controller.ReportInformation( + $"Fade {fadeDuration}을 다음 PREPARE부터 적용합니다."); + } + finally + { + _playoutCommandGate.Release(); + } + } + + private async Task ToggleBackgroundAsync( + CancellationToken cancellationToken) + { + if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다."); + } + + try + { + if (!CanChangeComposition()) + { + return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다."); + } + + var source = _compositionOptions!; + if (source.Current.BackgroundKind == LegacySceneBackgroundKind.None) + { + source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault( + _playoutOptions!, + source.Current.FadeDuration)); + _playoutInitializationWarning = null; + return _controller.ReportInformation( + "기본 배경을 다음 PREPARE부터 적용합니다."); + } + + source.Update(new LegacySceneCueCompositionOptions( + source.Current.FadeDuration, + LegacySceneBackgroundKind.None)); + return _controller.ReportInformation("배경 사용을 해제했습니다."); + } + catch (PlayoutConfigurationException exception) + { + return _controller.ReportError(exception.Message); + } + finally + { + _playoutCommandGate.Release(); + } + } + + private async Task ChooseBackgroundAsync( + CancellationToken cancellationToken) + { + if (!await _playoutCommandGate.WaitAsync(0, cancellationToken).ConfigureAwait(false)) + { + return _controller.ReportError("송출 명령 처리 중에는 배경을 변경할 수 없습니다."); + } + + try + { + if (!CanChangeComposition()) + { + return _controller.ReportError("배경은 IDLE 상태에서만 변경할 수 있습니다."); + } + + // MainForm.btnback_Click assigns 배경\기본.vrv before opening the + // picker. Preserve that observable behavior when the trusted default is + // present, while still allowing the operator to choose another asset if it + // is not. + var source = _compositionOptions!; + try + { + source.Update(PlayoutSceneCompositionFactory.CreateLegacyDefault( + _playoutOptions!, + source.Current.FadeDuration)); + _playoutInitializationWarning = null; + } + catch (PlayoutConfigurationException) + { + } + + var picker = new FileOpenPicker + { + SuggestedStartLocation = PickerLocationId.PicturesLibrary, + ViewMode = PickerViewMode.List + }; + foreach (var extension in new[] { ".vrv", ".jpg", ".jpeg", ".png" }) + { + picker.FileTypeFilter.Add(extension); + } + + InitializeWithWindow.Initialize(picker, WindowNative.GetWindowHandle(this)); + var selected = await picker.PickSingleFileAsync(); + if (selected is null) + { + return _controller.Current; + } + + if (!CanChangeComposition()) + { + return _controller.ReportError( + "파일 선택 중 송출 상태가 바뀌어 배경 변경을 취소했습니다."); + } + + source.Update(PlayoutSceneCompositionFactory.CreateOperatorSelection( + _playoutOptions!, + source.Current.FadeDuration, + selected.Path)); + _playoutInitializationWarning = null; + return _controller.ReportInformation( + "선택한 배경을 다음 PREPARE부터 적용합니다."); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or UnauthorizedAccessException or + PlayoutConfigurationException) + { + return _controller.ReportError( + "신뢰 배경 폴더 안의 vrv/jpg/jpeg/png 파일만 선택할 수 있습니다."); + } + finally + { + _playoutCommandGate.Release(); + } + } + + private bool CanChangeComposition() + { + var status = _playoutEngine?.Status; + var workflow = _playoutWorkflow?.State; + return _playoutOptions is not null && _compositionOptions is not null && + status is not null && workflow is not null && + Volatile.Read(ref _playoutBusy) == 0 && + Volatile.Read(ref _playoutQuarantined) == 0 && + !status.IsPlayCompletionPending && + string.IsNullOrWhiteSpace(status.PreparedSceneName) && + string.IsNullOrWhiteSpace(status.OnAirSceneName) && + string.IsNullOrWhiteSpace(workflow.PreparedCutCode) && + string.IsNullOrWhiteSpace(workflow.OnAirCutCode) && + (_refreshTask is null || _refreshTask.IsCompleted); + } + + private bool CanAcceptOperatorMutation() + { + var status = _playoutEngine?.Status; + var workflow = _playoutWorkflow?.State; + if (status is null || workflow is null) + { + // A disabled or unavailable playout runtime must not prevent the operator + // from composing a playlist. PREPARE will still fail closed later. + return true; + } + + return Volatile.Read(ref _playoutBusy) == 0 && + Volatile.Read(ref _playoutQuarantined) == 0 && + Volatile.Read(ref _playoutShutdownStarted) == 0 && + status.State != PlayoutConnectionState.OutcomeUnknown && + !status.IsPlayCompletionPending && + string.IsNullOrWhiteSpace(status.PreparedSceneName) && + string.IsNullOrWhiteSpace(status.OnAirSceneName) && + string.IsNullOrWhiteSpace(workflow.PreparedCutCode) && + string.IsNullOrWhiteSpace(workflow.OnAirCutCode) && + (_refreshTask is null || _refreshTask.IsCompleted); + } + + private static bool IsOperatorMutationIntent(LegacyUiIntent intent) => intent is + LegacyDropSelectedCutsIntent or + LegacySetPlaylistEnabledIntent or + LegacySetAllPlaylistEnabledIntent or + LegacyMoveSelectedPlaylistRowsIntent or + LegacyDeleteSelectedPlaylistRowsIntent or + LegacyDeleteAllPlaylistRowsIntent or + LegacyToggleActivePlaylistEnabledIntent or + LegacyActivateFixedActionIntent or + LegacyActivateFixedSectionIntent or + LegacyActivateIndustryActionIntent or + LegacyActivateIndustrySectionIntent or + LegacyActivateOverseasActionIntent or + LegacyAddComparisonPairIntent or + LegacyMoveComparisonPairIntent or + LegacyDeleteSelectedComparisonPairIntent or + LegacyDeleteAllComparisonPairsIntent or + LegacyActivateComparisonActionIntent or + LegacyActivateComparisonSectionIntent or + LegacyActivateThemeActionIntent or + LegacyActivateThemeResultIntent or + LegacyActivateExpertActionIntent or + LegacyActivateTradingHaltActionIntent or + LegacyBeginUc4ThemeCatalogIntent or + LegacyEditUc4SelectedThemeIntent or + LegacyDeleteUc4SelectedThemeIntent or + LegacyBeginUc6ExpertCatalogIntent or + LegacyEditUc6SelectedExpertIntent or + LegacyDeleteUc6SelectedExpertIntent or + LegacyBeginCreateThemeCatalogIntent or + LegacyBeginCreateExpertCatalogIntent or + LegacyAddOperatorCatalogStockIntent or + LegacyActivateOperatorCatalogStockIntent or + LegacyMoveOperatorCatalogDraftRowIntent or + LegacyRemoveOperatorCatalogDraftRowIntent or + LegacySetOperatorCatalogDraftBuyAmountIntent or + LegacySaveOperatorCatalogIntent or + LegacyDeleteOperatorCatalogIntent or + LegacyLoadSelectedNamedPlaylistIntent or + LegacyLoadNamedPlaylistByIdIntent or + LegacyCreateNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistToIntent or + LegacyDeleteSelectedNamedPlaylistIntent or + LegacySaveManualFinancialIntent or + LegacyDeleteManualFinancialIntent or + LegacyDeleteAllManualFinancialIntent or + LegacyActivateManualFinancialActionIntent or + LegacyActivateManualFinancialResultIntent or + LegacySetManualNetSellCellIntent or + LegacySaveManualNetSellIntent or + LegacyAddManualNetSellCutIntent or + LegacyAddManualViResultIntent or + LegacyMoveManualViItemIntent or + LegacyDeleteManualViItemIntent or + LegacyDeleteAllManualViItemsIntent or + LegacySaveManualViIntent or + LegacyAddManualViCutIntent or + LegacyConfirmManualListIntent or + LegacyImportManualListsIntent; + + private static bool IsFixedSectionBatchMutationIntent(LegacyUiIntent intent) => intent is + LegacySetManualNetSellCellIntent or + LegacyAddManualViResultIntent or + LegacyMoveManualViItemIntent or + LegacyDeleteManualViItemIntent or + LegacyDeleteAllManualViItemsIntent or + LegacyConfirmManualListIntent or + LegacyImportManualListsIntent; + + private void ObserveOperatorMutationQuarantine(LegacyOperatorSnapshot snapshot) + { + if (snapshot.ManualFinancial?.WritesQuarantined == true || + snapshot.NamedPlaylist?.IsWriteQuarantined == true || + snapshot.ManualLists?.WritesQuarantined == true || + snapshot.OperatorCatalog?.WritesQuarantined == true) + { + Interlocked.Exchange(ref _operatorMutationQuarantined, 1); + } + } + + private bool IsOperatorMutationQuarantined() => + Volatile.Read(ref _operatorMutationQuarantined) != 0; + + private LegacyOperatorPlayoutSnapshot CreatePlayoutSnapshot() + { + var status = _playoutEngine?.Status; + var workflow = _playoutWorkflow?.State; + var composition = _compositionOptions?.Current ?? + LegacySceneCueCompositionOptions.Default; + var quarantined = Volatile.Read(ref _playoutQuarantined) != 0 || + status?.State == PlayoutConnectionState.OutcomeUnknown; + var phase = !string.IsNullOrWhiteSpace(status?.OnAirSceneName) + ? LegacyOperatorPlayoutPhase.Program + : !string.IsNullOrWhiteSpace(status?.PreparedSceneName) + ? LegacyOperatorPlayoutPhase.Prepared + : LegacyOperatorPlayoutPhase.Idle; + var backgroundEnabled = + composition.BackgroundKind != LegacySceneBackgroundKind.None; + return new LegacyOperatorPlayoutSnapshot( + status?.Mode ?? PlayoutMode.Disabled, + quarantined + ? PlayoutConnectionState.OutcomeUnknown + : status?.State ?? PlayoutConnectionState.Disabled, + phase, + status?.IsProcessRunning ?? false, + status?.IsConnected ?? false, + (status?.IsCommandAvailable ?? false) && !quarantined, + (status?.LiveTakeInAllowed ?? false) && !quarantined, + status?.IsPlayCompletionPending ?? false, + quarantined, + status?.PreparedSceneName, + status?.OnAirSceneName, + workflow?.CurrentCueIndexZeroBased ?? -1, + workflow?.CurrentEntryId, + workflow?.BuilderKey, + workflow?.PageIndexZeroBased ?? 0, + workflow?.PageCount ?? 0, + workflow?.PageSize ?? 0, + workflow?.ItemCount ?? 0, + workflow?.CurrentPageItemCount ?? 0, + workflow?.IsLastPage ?? true, + workflow?.NextKind ?? LegacyWorkflowNextKind.None, + Volatile.Read(ref _playoutBusy) != 0, + _refreshTask is { IsCompleted: false }, + Volatile.Read(ref _refreshCompletedCount), + _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn, + _refreshLimitReached, + composition.FadeDuration, + backgroundEnabled, + backgroundEnabled + ? Path.GetFileName(composition.BackgroundAssetPath) ?? string.Empty + : string.Empty, + CanChangeComposition(), + quarantined + ? "결과 불명확 상태입니다. 명령을 반복하지 마세요." + : _refreshMessage.Length > 0 + ? _refreshMessage + : _playoutInitializationWarning ?? status?.Message ?? + _playoutInitializationError ?? + "송출 어댑터를 사용할 수 없습니다."); + } + + private void StartRefreshLoop(LegacyPlayoutWorkflow workflow) + { + StopRefreshLoop(); + Interlocked.Exchange(ref _refreshCompletedCount, 0); + _refreshLimitReached = false; + _refreshMessage = string.Empty; + var cutCode = workflow.State.OnAirCutCode; + if (!LegacySceneRefreshPolicy.TryGetInterval(cutCode, out var firstInterval) || + firstInterval <= TimeSpan.Zero) + { + return; + } + + var cancellation = CancellationTokenSource.CreateLinkedTokenSource( + _lifetimeCancellation.Token); + _refreshCancellation = cancellation; + _refreshTask = RunRefreshLoopAsync(workflow, firstInterval, cancellation); + } + + private void StopRefreshLoop() + { + var cancellation = Interlocked.Exchange(ref _refreshCancellation, null); + if (cancellation is not null) + { + try + { + cancellation.Cancel(); + } + catch (ObjectDisposedException) + { + } + } + } + + private async Task RunRefreshLoopAsync( + LegacyPlayoutWorkflow workflow, + TimeSpan firstInterval, + CancellationTokenSource cancellation) + { + var token = cancellation.Token; + var delay = firstInterval; + var maximum = _playoutOptions?.MaximumAutomaticRefreshesPerTakeIn; + try + { + while (!token.IsCancellationRequested) + { + var engine = _playoutEngine; + if (engine is null) + { + return; + } + + if (maximum is { } cap && Volatile.Read(ref _refreshCompletedCount) >= cap) + { + if (!await WaitForPlayCompletionAsync(engine, token) + .ConfigureAwait(false)) + { + _refreshMessage = + "자동 갱신 상한에 도달했지만 마지막 재생 완료 이벤트를 확인하지 못했습니다. PGM을 확인하세요."; + return; + } + + _refreshLimitReached = true; + _refreshMessage = $"자동 갱신 상한 {cap}회에 도달했습니다."; + return; + } + + if (!await WaitForPlayCompletionAsync(engine, token) + .ConfigureAwait(false)) + { + _refreshMessage = "재생 완료 이벤트를 확인하지 못해 자동 갱신을 중단했습니다."; + return; + } + + await Task.Delay(delay, token).ConfigureAwait(false); + await _playoutCommandGate.WaitAsync(token).ConfigureAwait(false); + try + { + Interlocked.Exchange(ref _playoutBusy, 1); + var result = await workflow.RefreshOnAirAsync(token).ConfigureAwait(false); + if (!result.IsSuccess) + { + if (result.Code is PlayoutResultCode.OutcomeUnknown or + PlayoutResultCode.TimedOut) + { + await QuarantinePlayoutAsync().ConfigureAwait(false); + } + + _refreshMessage = + "자동 장면 갱신을 중단했습니다. TAKE OUT 전 PGM을 확인하세요."; + return; + } + + Interlocked.Increment(ref _refreshCompletedCount); + delay = LegacySceneRefreshPolicy.RecurringInterval; + } + finally + { + Interlocked.Exchange(ref _playoutBusy, 0); + _playoutCommandGate.Release(); + QueueOperatorState(); + } + } + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + catch + { + await QuarantinePlayoutAsync().ConfigureAwait(false); + _refreshMessage = "자동 갱신 결과가 불명확합니다. 명령을 반복하지 마세요."; + } + finally + { + if (ReferenceEquals(_refreshCancellation, cancellation)) + { + Interlocked.CompareExchange(ref _refreshCancellation, null, cancellation); + } + + cancellation.Dispose(); + QueueOperatorState(); + } + } + + private static async Task WaitForPlayCompletionAsync( + IPlayoutEngine engine, + CancellationToken cancellationToken) + { + if (!engine.Status.IsPlayCompletionPending) + { + return true; + } + + var timeout = Math.Clamp( + engine.Status.OperationTimeoutMilliseconds, + 100, + 300_000); + using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linked.CancelAfter(timeout); + while (engine.Status.IsPlayCompletionPending) + { + var completion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + void OnStatus(object? sender, PlayoutStatusChangedEventArgs args) + { + if (!args.Current.IsPlayCompletionPending) + { + completion.TrySetResult(); + } + } + + engine.StatusChanged += OnStatus; + try + { + if (!engine.Status.IsPlayCompletionPending) + { + return true; + } + + await completion.Task.WaitAsync(linked.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return false; + } + finally + { + engine.StatusChanged -= OnStatus; + } + } + + return true; + } + + private async ValueTask QuarantinePlayoutAsync() + { + if (Interlocked.Exchange(ref _playoutQuarantined, 1) != 0) + { + return; + } + + StopRefreshLoop(); + if (_playoutEngine is not null) + { + try + { + await _playoutEngine.QuarantineAsync().ConfigureAwait(false); + } + catch + { + } + } + } + + private void QueueOperatorState() + { + if (_closing || !_webViewReady) + { + return; + } + + DispatcherQueue.TryEnqueue(() => + { + if (!_closing) + { + PostState(_controller.Current); + } + }); + } + + private async Task ShutdownPlayoutRuntimeAsync() + { + if (Interlocked.Exchange(ref _playoutShutdownStarted, 1) != 0) + { + return; + } + + StopRefreshLoop(); + var refresh = _refreshTask; + if (refresh is not null) + { + try + { + await refresh.WaitAsync(TimeSpan.FromSeconds(2)); + } + catch + { + } + } + + var engine = _playoutEngine; + _playoutEngine = null; + _playoutWorkflow = null; + if (engine is null) + { + return; + } + + engine.StatusChanged -= OnPlayoutStatusChanged; + try + { + if (Volatile.Read(ref _playoutQuarantined) == 0 && + engine.Status.State != PlayoutConnectionState.OutcomeUnknown && + string.IsNullOrWhiteSpace(engine.Status.OnAirSceneName) && + !engine.Status.IsPlayCompletionPending) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + await engine.DisconnectAsync(timeout.Token).ConfigureAwait(false); + } + } + catch + { + } + + await engine.DisposeAsync().ConfigureAwait(false); + } + + private void ShutdownPlayoutRuntime() + { + try + { + ShutdownPlayoutRuntimeAsync() + .Wait(TimeSpan.FromSeconds(8)); + } + catch + { + // Window shutdown is bounded and best-effort. The engine itself keeps an + // outcome-unknown session quarantined and will not send a duplicate BYE. + } + } +} diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs index 011fafe..5a84bc2 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/MainWindow.xaml.cs @@ -14,6 +14,7 @@ public sealed partial class MainWindow : Window private readonly CancellationTokenSource _lifetimeCancellation = new(); private readonly SemaphoreSlim _intentGate = new(1, 1); private readonly LegacyOperatorController _controller; + private readonly LegacyIndustrySelectionWorkflow _industryWorkflow; private DatabaseRuntime? _databaseRuntime; private bool _closing; private bool _intentBusy; @@ -22,29 +23,144 @@ public sealed partial class MainWindow : Window public MainWindow() { InitializeComponent(); + EnsureCloseConfirmationAttached(); ILegacyStockLookup stockLookup; + IIndustrySelectionService industrySelectionService; + IThemeSelectionService themeSelectionService; + IExpertSelectionService expertSelectionService; + ITradingHaltSelectionService tradingHaltSelectionService; + IOverseasIndustryIndexSearchService overseasIndustrySearchService; + IWorldStockSearchService worldStockSearchService; + IManualFinancialScreenService manualFinancialService; + IStockSearchService manualFinancialStockSearchService; + INamedPlaylistPersistenceService namedPlaylistPersistenceService; + IThemeCatalogPersistenceService themeCatalogPersistenceService; + IExpertCatalogPersistenceService expertCatalogPersistenceService; + IStockMasterIdentityValidationService stockMasterIdentityValidationService; + IOperatorCatalogSchemaValidationService operatorCatalogSchemaValidationService; string? initializationError = null; try { _databaseRuntime = DatabaseRuntime.CreateDefault(); DataQueryExecutor.Configure(_databaseRuntime.Executor); stockLookup = new LegacyParityStockSearchService(_databaseRuntime.Executor); + industrySelectionService = new LegacyIndustrySelectionService( + _databaseRuntime.Executor); + themeSelectionService = new LegacyThemeSelectionService( + _databaseRuntime.Executor); + expertSelectionService = new LegacyExpertSelectionService( + _databaseRuntime.Executor); + tradingHaltSelectionService = new LegacyTradingHaltSelectionService( + _databaseRuntime.Executor); + overseasIndustrySearchService = new LegacyOverseasIndustryIndexSearchService( + _databaseRuntime.Executor); + worldStockSearchService = new LegacyWorldStockSearchService( + _databaseRuntime.Executor); + manualFinancialService = new LegacyManualFinancialScreenService( + _databaseRuntime.Executor, + new OracleManualFinancialMutationExecutor( + _databaseRuntime.ConnectionFactory, + _databaseRuntime.Options.Resilience)); + manualFinancialStockSearchService = new LegacyStockSearchService( + _databaseRuntime.Executor); + namedPlaylistPersistenceService = new LegacyNamedPlaylistPersistenceService( + _databaseRuntime.Executor, + new OracleNamedPlaylistMutationExecutor( + _databaseRuntime.ConnectionFactory, + _databaseRuntime.Options.Resilience)); + var operatorCatalogMutationExecutor = new OperatorCatalogMutationExecutor( + _databaseRuntime.ConnectionFactory, + _databaseRuntime.Options.Resilience); + themeCatalogPersistenceService = new LegacyThemeCatalogPersistenceService( + _databaseRuntime.Executor, + operatorCatalogMutationExecutor); + expertCatalogPersistenceService = new LegacyExpertCatalogPersistenceService( + _databaseRuntime.Executor, + operatorCatalogMutationExecutor); + stockMasterIdentityValidationService = + new LegacyStockMasterIdentityValidationService(_databaseRuntime.Executor); + operatorCatalogSchemaValidationService = + new LegacyOperatorCatalogSchemaValidationService(_databaseRuntime.Executor); } catch { initializationError = "데이터베이스가 설정되지 않았습니다. 로컬 설정을 확인하세요."; stockLookup = new UnavailableStockLookup(initializationError); + industrySelectionService = new UnavailableIndustrySelectionService( + initializationError); + themeSelectionService = new UnavailableThemeSelectionService( + initializationError); + expertSelectionService = new UnavailableExpertSelectionService( + initializationError); + tradingHaltSelectionService = new UnavailableTradingHaltSelectionService( + initializationError); + overseasIndustrySearchService = new UnavailableOverseasIndustryIndexSearchService( + initializationError); + worldStockSearchService = new UnavailableWorldStockSearchService( + initializationError); + manualFinancialService = new UnavailableManualFinancialScreenService( + initializationError); + manualFinancialStockSearchService = new UnavailableStockSearchService( + initializationError); + namedPlaylistPersistenceService = new UnavailableNamedPlaylistPersistenceService( + initializationError); + themeCatalogPersistenceService = new UnavailableThemeCatalogPersistenceService( + initializationError); + expertCatalogPersistenceService = new UnavailableExpertCatalogPersistenceService( + initializationError); + stockMasterIdentityValidationService = + new UnavailableStockMasterIdentityValidationService(initializationError); + operatorCatalogSchemaValidationService = + new UnavailableOperatorCatalogSchemaValidationService(initializationError); DataQueryExecutor.Reset(); } - _controller = new LegacyOperatorController(stockLookup); + _industryWorkflow = new LegacyIndustrySelectionWorkflow(industrySelectionService); + var overseasWorkflow = new LegacyOverseasSelectionWorkflow( + overseasIndustrySearchService, + worldStockSearchService); + var comparisonStore = new LegacyComparisonPairFileStore( + Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "MBN_STOCK_WEBVIEW", + "Data", + "종목비교.dat"), + Path.Combine(AppContext.BaseDirectory, "Data", "종목비교.dat")); + var manualListsWorkflow = CreateManualListsWorkflow(stockLookup); + _controller = new LegacyOperatorController( + stockLookup, + industryWorkflow: _industryWorkflow, + themeWorkflow: new LegacyThemeWorkflow(themeSelectionService), + expertWorkflow: new LegacyExpertWorkflowController(expertSelectionService), + tradingHaltWorkflow: new LegacyTradingHaltWorkflowController( + tradingHaltSelectionService), + comparisonWorkflow: new LegacyComparisonWorkflowController( + new LegacyComparisonDataService(stockLookup, worldStockSearchService), + comparisonStore), + manualFinancialWorkflow: new LegacyManualFinancialWorkflow( + manualFinancialService, + manualFinancialStockSearchService), + namedPlaylistWorkflow: new LegacyNamedPlaylistWorkflowController( + namedPlaylistPersistenceService), + overseasWorkflow: overseasWorkflow, + manualListsWorkflow: manualListsWorkflow, + operatorCatalogWorkflow: new LegacyOperatorCatalogWorkflowController( + themeSelectionService, + expertSelectionService, + themeCatalogPersistenceService, + expertCatalogPersistenceService, + manualFinancialStockSearchService, + stockMasterIdentityValidationService, + operatorCatalogSchemaValidationService)); if (initializationError is not null) { _controller.ReportError(initializationError); } + InitializePlayoutRuntime(); + Root.Loaded += OnRootLoaded; Closed += OnClosed; } @@ -59,10 +175,15 @@ public sealed partial class MainWindow : Window ConfigureWindow(); await InitializeWebViewAsync(); + if (!_closing) + { + await ConnectPlayoutAsync(_lifetimeCancellation.Token); + } } private void ConfigureWindow() { + EnsureCloseConfirmationAttached(); var windowHandle = WindowNative.GetWindowHandle(this); var windowId = Microsoft.UI.Win32Interop.GetWindowIdFromWindow(windowHandle); var appWindow = AppWindow.GetFromWindowId(windowId); @@ -208,15 +329,94 @@ public sealed partial class MainWindow : Window return; } - if (intent is LegacySearchStocksIntent searchIntent && - !(searchIntent.Trigger == LegacySearchTrigger.Button && - searchIntent.Text.Length == 0)) + if (IsOperatorMutationIntent(intent) && IsOperatorMutationQuarantined()) + { + PostState(_controller.ReportError( + "이전 저장 결과가 불명확하여 이번 실행의 추가 변경이 차단되었습니다. DB 또는 로컬 파일을 읽기 전용으로 대조한 뒤 앱을 다시 시작하세요.")); + return; + } + + if (_controller.Current.FixedSectionBatch is not null && + IsOperatorMutationIntent(intent) && + !IsFixedSectionBatchMutationIntent(intent)) + { + PostState(_controller.ReportError( + "고정 컷 섹션의 수동 입력 중에는 현재 입력 단계만 변경할 수 있습니다. 완료하거나 취소한 뒤 다른 편성 작업을 실행하세요.")); + return; + } + + if (IsOperatorMutationIntent(intent) && !CanAcceptOperatorMutation()) + { + PostState(_controller.ReportError( + "PREPARE 또는 송출 중에는 플레이리스트와 운영 데이터를 변경할 수 없습니다. TAKE OUT 후 다시 시도하세요.")); + return; + } + + if ((intent is LegacySearchStocksIntent searchIntent && + !(searchIntent.Trigger == LegacySearchTrigger.Button && + searchIntent.Text.Length == 0)) || + intent is LegacySelectTabIntent or + LegacySearchThemesIntent or LegacySelectThemeIntent or + LegacyActivateThemeResultIntent or + LegacySearchExpertsIntent or LegacySelectExpertIntent or + LegacySearchTradingHaltsIntent or + LegacySearchComparisonDomesticIntent or + LegacySearchComparisonWorldIntent or + LegacySearchOverseasIndustriesIntent or + LegacySearchOverseasStocksIntent or + LegacyOpenOperatorCatalogIntent or + LegacyBeginUc4ThemeCatalogIntent or + LegacyEditUc4SelectedThemeIntent or + LegacyDeleteUc4SelectedThemeIntent or + LegacyBeginUc6ExpertCatalogIntent or + LegacyEditUc6SelectedExpertIntent or + LegacyDeleteUc6SelectedExpertIntent or + LegacySearchOperatorCatalogIntent or + LegacySelectOperatorCatalogResultIntent or + LegacyBeginCreateThemeCatalogIntent or + LegacyBeginCreateExpertCatalogIntent or + LegacySearchOperatorCatalogStocksIntent or + LegacyActivateOperatorCatalogStockIntent or + LegacySetOperatorCatalogDraftBuyAmountIntent or + LegacySaveOperatorCatalogIntent or + LegacyDeleteOperatorCatalogIntent or + LegacyAddComparisonPairIntent or + LegacyMoveComparisonPairIntent or + LegacyDeleteSelectedComparisonPairIntent or + LegacyDeleteAllComparisonPairsIntent or + LegacyOpenManualFinancialIntent or + LegacySearchManualFinancialIntent or + LegacyFindManualFinancialIntent or + LegacyLoadManualFinancialIntent or + LegacyActivateManualFinancialResultIntent or + LegacySearchManualFinancialStocksIntent or + LegacySaveManualFinancialIntent or + LegacyDeleteManualFinancialIntent or + LegacyDeleteAllManualFinancialIntent or + LegacyOpenManualListIntent or + LegacyRefreshManualListIntent or + LegacySaveManualNetSellIntent or + LegacySearchManualViIntent or + LegacySaveManualViIntent or + LegacyImportManualListsIntent or + LegacyRefreshNamedPlaylistsIntent or + LegacyLoadSelectedNamedPlaylistIntent or + LegacyLoadNamedPlaylistByIdIntent or + LegacyCreateNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistIntent or + LegacySaveCurrentNamedPlaylistToIntent or + LegacyDeleteSelectedNamedPlaylistIntent or + LegacyExecutePlayoutIntent or + LegacySetFadeDurationIntent or + LegacyToggleBackgroundIntent or + LegacyChooseBackgroundIntent) { _intentBusy = true; PostState(_controller.Current); } var state = await HandleIntentAsync(intent, _lifetimeCancellation.Token); + ObserveOperatorMutationQuarantine(state); if (!_closing) { _intentBusy = false; @@ -272,6 +472,318 @@ public sealed partial class MainWindow : Window LegacyDropSelectedCutsIntent => _controller.DropSelectedCutsOnPlaylist(), LegacySetPlaylistEnabledIntent enabled => _controller.SetPlaylistRowEnabled(enabled.RowId, enabled.Enabled), + LegacySetAllPlaylistEnabledIntent enabled => + _controller.SetAllPlaylistRowsEnabled(enabled.Enabled), + LegacySelectPlaylistRowIntent selection => + _controller.SelectPlaylistRow( + selection.RowId, + selection.Control, + selection.Shift), + LegacyMoveSelectedPlaylistRowsIntent move => + _controller.MoveSelectedPlaylistRows(move.Direction), + LegacyDeleteSelectedPlaylistRowsIntent => + _controller.DeleteSelectedPlaylistRows(), + LegacyDeleteAllPlaylistRowsIntent => + _controller.DeleteAllPlaylistRows(), + LegacyToggleActivePlaylistEnabledIntent => + _controller.ToggleActivePlaylistRowEnabled(), + LegacySelectPlaylistBoundaryIntent boundary => + _controller.SelectPlaylistBoundary(boundary.Last), + LegacySelectTabIntent tab => + await _controller.SelectTabAsync(tab.TabId, cancellationToken), + LegacySwapTabsIntent tabs => + await _controller.SwapTabsAsync( + tabs.Source, + tabs.Target, + cancellationToken), + LegacySetMovingAveragesIntent movingAverages => + _controller.SetMovingAverages(movingAverages.Ma5, movingAverages.Ma20), + LegacyActivateFixedActionIntent action => + await _controller.ActivateFixedActionAsync( + action.ActionId, + cancellationToken), + LegacyActivateFixedSectionIntent section => + await _controller.ActivateFixedSection( + section.SectionIndex, + cancellationToken), + LegacySelectIndustryIntent industry => + _controller.SelectIndustry(industry.Index, industry.DoubleClick), + LegacySwapIndustriesIntent => + _controller.SwapIndustries(), + LegacyActivateIndustryActionIntent action => + _controller.ActivateIndustryAction(action.ActionId), + LegacyActivateIndustrySectionIntent section => + _controller.ActivateIndustrySection(section.SectionIndex), + LegacySelectOverseasFixedIndexIntent selection => + _controller.SelectOverseasFixedIndex(selection.TargetId), + LegacySearchOverseasIndustriesIntent search => + await _controller.SearchOverseasIndustriesAsync( + search.Query, + cancellationToken), + LegacySearchOverseasStocksIntent search => + await _controller.SearchOverseasStocksAsync( + search.Query, + cancellationToken), + LegacySelectOverseasIndustryIntent selection => + _controller.SelectOverseasIndustry(selection.SelectionId), + LegacySelectOverseasStockIntent selection => + _controller.SelectOverseasStock(selection.SelectionId), + LegacyActivateOverseasActionIntent action => + _controller.ActivateOverseasAction(action.ActionId), + LegacySearchComparisonDomesticIntent search => + await _controller.SearchComparisonDomesticAsync( + search.Query, + cancellationToken), + LegacySearchComparisonWorldIntent search => + await _controller.SearchComparisonWorldAsync( + search.Query, + cancellationToken), + LegacySelectComparisonDomesticIntent selection => + _controller.SelectComparisonDomesticResult(selection.SelectionId), + LegacySelectComparisonWorldIntent selection => + _controller.SelectComparisonWorldResult(selection.SelectionId), + LegacySelectComparisonFixedIntent selection => + _controller.SelectComparisonFixedTarget(selection.TargetId), + LegacyChooseComparisonDomesticIntent selection => + _controller.ChooseComparisonDomesticResult(selection.SelectionId), + LegacyChooseComparisonWorldIntent selection => + _controller.ChooseComparisonWorldResult(selection.SelectionId), + LegacyChooseComparisonFixedIntent selection => + _controller.ChooseComparisonFixedTarget(selection.TargetId), + LegacySwapComparisonTargetsIntent => + _controller.SwapComparisonTargets(), + LegacyClearComparisonTargetsIntent => + _controller.ClearComparisonTargets(), + LegacyAddComparisonPairIntent => + await _controller.AddComparisonPairAsync(cancellationToken), + LegacySelectComparisonPairIntent selection => + _controller.SelectComparisonPair(selection.RowId), + LegacyMoveComparisonPairIntent move => + await _controller.MoveComparisonPairAsync( + move.Direction, + cancellationToken), + LegacyDeleteSelectedComparisonPairIntent => + await _controller.DeleteComparisonPairAsync( + all: false, + cancellationToken), + LegacyDeleteAllComparisonPairsIntent => + await _controller.DeleteComparisonPairAsync( + all: true, + cancellationToken), + LegacyActivateComparisonActionIntent action => + _controller.ActivateComparisonAction(action.ActionId), + LegacyActivateComparisonSectionIntent section => + _controller.ActivateComparisonSection(section.SectionIndex), + LegacySearchThemesIntent search => + await _controller.SearchThemesAsync( + search.Query, + search.NxtSession, + cancellationToken), + LegacySelectThemeIntent selection => + await _controller.SelectThemeAsync( + selection.ResultId, + cancellationToken), + LegacyActivateThemeResultIntent activation => + await _controller.ActivateThemeResultAsync( + activation.ResultId, + cancellationToken), + LegacySetThemeSortIntent sort => + _controller.SetThemeSort(sort.SortId), + LegacyActivateThemeActionIntent action => + _controller.ActivateThemeAction(action.ActionId), + LegacySearchExpertsIntent search => + await _controller.SearchExpertsAsync(search.Query, cancellationToken), + LegacySelectExpertIntent selection => + await _controller.SelectExpertAsync( + selection.SelectionId, + cancellationToken), + LegacyActivateExpertActionIntent action => + _controller.ActivateExpertAction(action.ActionId), + LegacySearchTradingHaltsIntent search => + await _controller.SearchTradingHaltsAsync( + search.Query, + cancellationToken), + LegacySelectTradingHaltIntent selection => + _controller.SelectTradingHalt(selection.SelectionId), + LegacyToggleTradingHaltSortIntent => + _controller.ToggleTradingHaltSort(), + LegacyActivateTradingHaltActionIntent action => + _controller.ActivateTradingHaltAction(action.ActionId), + LegacyOpenOperatorCatalogIntent catalog => + await _controller.OpenOperatorCatalogAsync( + catalog.Entity, + cancellationToken), + LegacyBeginUc4ThemeCatalogIntent => + await _controller.BeginThemeCatalogFromUc4Async(cancellationToken), + LegacyEditUc4SelectedThemeIntent => + await _controller.EditSelectedThemeCatalogAsync(cancellationToken), + LegacyDeleteUc4SelectedThemeIntent => + await _controller.DeleteSelectedThemeCatalogAsync(cancellationToken), + LegacyBeginUc6ExpertCatalogIntent => + await _controller.BeginExpertCatalogFromUc6Async(cancellationToken), + LegacyEditUc6SelectedExpertIntent => + await _controller.EditSelectedExpertCatalogAsync(cancellationToken), + LegacyDeleteUc6SelectedExpertIntent => + await _controller.DeleteSelectedExpertCatalogAsync(cancellationToken), + LegacyCloseOperatorCatalogIntent => + _controller.CloseOperatorCatalog(), + LegacySearchOperatorCatalogIntent search => + await _controller.SearchOperatorCatalogAsync( + search.Query, + search.NxtSession, + cancellationToken), + LegacySelectOperatorCatalogResultIntent selection => + await _controller.SelectOperatorCatalogResultAsync( + selection.ResultId, + cancellationToken), + LegacyBeginCreateThemeCatalogIntent create => + await _controller.BeginCreateThemeCatalogAsync( + create.Market, + cancellationToken), + LegacyBeginCreateExpertCatalogIntent => + await _controller.BeginCreateExpertCatalogAsync(cancellationToken), + LegacySearchOperatorCatalogStocksIntent search => + await _controller.SearchOperatorCatalogStocksAsync( + search.Query, + cancellationToken), + LegacySelectOperatorCatalogStockIntent selection => + _controller.SelectOperatorCatalogStock(selection.ResultId), + LegacyAddOperatorCatalogStockIntent add => + _controller.AddOperatorCatalogStock(add.BuyAmount), + LegacyActivateOperatorCatalogStockIntent activation => + _controller.ActivateOperatorCatalogStock( + activation.ResultId, + activation.BuyAmount), + LegacyMoveOperatorCatalogDraftRowIntent move => + _controller.MoveOperatorCatalogDraftRow(move.RowId, move.Direction), + LegacyRemoveOperatorCatalogDraftRowIntent remove => + _controller.RemoveOperatorCatalogDraftRow(remove.RowId), + LegacySetOperatorCatalogDraftBuyAmountIntent amount => + _controller.SetOperatorCatalogDraftBuyAmount( + amount.RowId, + amount.BuyAmount), + LegacySaveOperatorCatalogIntent save => + await _controller.SaveOperatorCatalogAsync( + save.Name, + cancellationToken), + LegacyDeleteOperatorCatalogIntent => + await _controller.DeleteOperatorCatalogAsync(cancellationToken), + LegacyRefreshNamedPlaylistsIntent => + await _controller.RefreshNamedPlaylistsAsync(cancellationToken), + LegacySelectNamedPlaylistIntent selection => + _controller.SelectNamedPlaylist(selection.DefinitionId), + LegacyLoadSelectedNamedPlaylistIntent => + await _controller.LoadSelectedNamedPlaylistAsync(cancellationToken), + LegacyLoadNamedPlaylistByIdIntent load => + await _controller.LoadNamedPlaylistByIdAsync( + load.DefinitionId, + cancellationToken), + LegacyCreateNamedPlaylistIntent create => + await _controller.CreateNamedPlaylistAsync( + create.Title, + cancellationToken), + LegacySaveCurrentNamedPlaylistIntent => + await _controller.SaveCurrentNamedPlaylistAsync(cancellationToken), + LegacySaveCurrentNamedPlaylistToIntent save => + await _controller.SaveCurrentNamedPlaylistToAsync( + save.DefinitionId, + cancellationToken), + LegacyDeleteSelectedNamedPlaylistIntent => + await _controller.DeleteSelectedNamedPlaylistAsync(cancellationToken), + LegacyOpenManualFinancialIntent manual => + await _controller.OpenManualFinancialAsync( + manual.Screen, + cancellationToken), + LegacyCloseManualFinancialIntent => + _controller.CloseManualFinancial(), + LegacySearchManualFinancialIntent search => + await _controller.SearchManualFinancialAsync( + search.Query, + cancellationToken), + LegacyFindManualFinancialIntent find => + await _controller.FindManualFinancialAsync( + find.Query, + find.Direction, + find.Generation, + cancellationToken), + LegacyToggleManualFinancialNameSortIntent sort => + _controller.ToggleManualFinancialNameSort(sort.Generation), + LegacyLoadManualFinancialIntent load => + await _controller.LoadManualFinancialAsync( + load.ResultId, + cancellationToken), + LegacyActivateManualFinancialResultIntent activation => + await _controller.ActivateManualFinancialResultAsync( + activation.ResultId, + cancellationToken), + LegacyBeginManualFinancialCreateIntent => + _controller.BeginManualFinancialCreate(), + LegacySearchManualFinancialStocksIntent search => + await _controller.SearchManualFinancialStocksAsync( + search.StockName, + cancellationToken), + LegacySelectManualFinancialStockIntent selection => + _controller.SelectManualFinancialStock(selection.ResultId), + LegacySaveManualFinancialIntent save => + await _controller.SaveManualFinancialAsync( + save.Record, + cancellationToken), + LegacyDeleteManualFinancialIntent => + await _controller.DeleteManualFinancialAsync( + all: false, + cancellationToken), + LegacyDeleteAllManualFinancialIntent => + await _controller.DeleteManualFinancialAsync( + all: true, + cancellationToken), + LegacyActivateManualFinancialActionIntent action => + _controller.ActivateManualFinancialAction(action.ActionId), + LegacyOpenManualListIntent manual => + manual.Screen == LegacyManualListScreen.NetSell + ? await _controller.OpenManualNetSellAsync( + manual.Audience, + cancellationToken) + : await _controller.OpenManualViAsync(cancellationToken), + LegacyCloseManualListIntent => + _controller.CloseManualLists(), + LegacyRefreshManualListIntent => + await _controller.RefreshManualListsAsync(cancellationToken), + LegacySetManualNetSellCellIntent cell => + _controller.SetManualNetSellCell(cell.RowId, cell.Field, cell.Value), + LegacySaveManualNetSellIntent => + await _controller.SaveManualNetSellAsync(cancellationToken), + LegacyAddManualNetSellCutIntent => + _controller.AddManualNetSellPlaylistRow(), + LegacySearchManualViIntent search => + await _controller.SearchManualViAsync(search.Query, cancellationToken), + LegacySelectManualViResultIntent result => + _controller.SelectManualViSearchResult(result.ResultId), + LegacyAddManualViResultIntent result => + _controller.AddManualViSearchResult(result.ResultId), + LegacyMoveManualViItemIntent move => + _controller.MoveManualViItem(move.ItemId, move.Direction), + LegacyDeleteManualViItemIntent item => + _controller.DeleteManualViItem(item.ItemId), + LegacyDeleteAllManualViItemsIntent => + _controller.DeleteAllManualViItems(), + LegacySaveManualViIntent => + await _controller.SaveManualViAsync(cancellationToken), + LegacyAddManualViCutIntent => + _controller.AddManualViPlaylistRow(), + LegacyConfirmManualListIntent => + await _controller.ConfirmManualListsAsync(cancellationToken), + LegacyImportManualListsIntent => + await _controller.ImportLegacyManualListsAsync(cancellationToken), + LegacyExecutePlayoutIntent playout => + await ExecuteOperatorPlayoutAsync( + playout.Command, + cancellationToken), + LegacySetFadeDurationIntent fade => + await SetFadeDurationAsync(fade.Duration, cancellationToken), + LegacyToggleBackgroundIntent => + await ToggleBackgroundAsync(cancellationToken), + LegacyChooseBackgroundIntent => + await ChooseBackgroundAsync(cancellationToken), LegacyDismissDialogIntent => _controller.DismissDialog(), _ => _controller.ReportError("지원하지 않는 화면 요청입니다.") }; @@ -282,12 +794,17 @@ public sealed partial class MainWindow : Window if (!_closing && _webViewReady && Browser.CoreWebView2 is not null) { Browser.CoreWebView2.PostWebMessageAsJson( - LegacyBridgeProtocol.SerializeState(snapshot, _intentBusy)); + LegacyBridgeProtocol.SerializeState( + snapshot, + _intentBusy, + CreatePlayoutSnapshot())); } } private void OnProcessFailed(object? sender, CoreWebView2ProcessFailedEventArgs args) { + ObserveOperatorMutationQuarantine( + _controller.InvalidateOperatorCatalogBrowserCorrelation()); if (!_closing) { ShowError("WebView2 프로세스가 중단되었습니다.", args.ProcessFailedKind.ToString()); @@ -314,9 +831,14 @@ public sealed partial class MainWindow : Window } _closing = true; + ObserveOperatorMutationQuarantine( + _controller.InvalidateOperatorCatalogBrowserCorrelation()); Root.Loaded -= OnRootLoaded; Closed -= OnClosed; + DetachCloseConfirmation(); _lifetimeCancellation.Cancel(); + ShutdownPlayoutRuntime(); + ShutdownManualListsRuntime(); DataQueryExecutor.Reset(); if (Browser.CoreWebView2 is not null) @@ -335,6 +857,166 @@ public sealed partial class MainWindow : Window // cancellation and release the gate without racing disposed objects. } + private sealed class UnavailableThemeCatalogPersistenceService + : IThemeCatalogPersistenceService + { + private readonly string _message; + + public UnavailableThemeCatalogPersistenceService(string message) + { + _message = message; + } + + public bool CanMutate => false; + + public Task SuggestNextCodeAsync( + ThemeMarket market, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task CreateAsync( + ThemeCatalogDefinition definition, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task ReplaceAsync( + ThemeSelectionIdentity expectedIdentity, + string newTitle, + IReadOnlyList items, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task DeleteAsync( + ThemeSelectionIdentity expectedIdentity, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableExpertCatalogPersistenceService + : IExpertCatalogPersistenceService + { + private readonly string _message; + + public UnavailableExpertCatalogPersistenceService(string message) + { + _message = message; + } + + public bool CanMutate => false; + + public Task SuggestNextCodeAsync( + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task CreateAsync( + ExpertCatalogDefinition definition, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task ReplaceAsync( + ExpertSelectionIdentity expectedIdentity, + string newName, + IReadOnlyList recommendations, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task DeleteAsync( + ExpertSelectionIdentity expectedIdentity, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableStockMasterIdentityValidationService + : IStockMasterIdentityValidationService + { + private readonly string _message; + + public UnavailableStockMasterIdentityValidationService(string message) + { + _message = message; + } + + public Task> ValidateThemeItemsAsync( + IReadOnlyList items, + CancellationToken cancellationToken = default) => + Task.FromException>( + new InvalidOperationException(_message)); + + public Task> ValidateExpertRecommendationsAsync( + IReadOnlyList recommendations, + CancellationToken cancellationToken = default) => + Task.FromException>( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableOperatorCatalogSchemaValidationService + : IOperatorCatalogSchemaValidationService + { + private readonly string _message; + + public UnavailableOperatorCatalogSchemaValidationService(string message) + { + _message = message; + } + + public Task ValidateAsync( + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableNamedPlaylistPersistenceService + : INamedPlaylistPersistenceService + { + private readonly string _message; + + public UnavailableNamedPlaylistPersistenceService(string message) + { + _message = message; + } + + public bool CanMutate => false; + + public Task ListAsync( + int maximumResults = LegacyNamedPlaylistPersistenceService.DefaultMaximumDefinitions, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task SuggestNextProgramCodeAsync( + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task LoadAsync( + string programCode, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task CreateDefinitionAsync( + string programCode, + string title, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task ReplaceItemsAsync( + string programCode, + IReadOnlyList items, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task DeleteAsync( + string programCode, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + } + private sealed class UnavailableStockLookup : ILegacyStockLookup { private readonly string _message; @@ -350,4 +1032,185 @@ public sealed partial class MainWindow : Window Task.FromException( new InvalidOperationException(_message)); } + + private sealed class UnavailableIndustrySelectionService : IIndustrySelectionService + { + private readonly string _message; + + public UnavailableIndustrySelectionService(string message) + { + _message = message; + } + + public Task> GetAsync( + IndustryMarket market, + CancellationToken cancellationToken = default) => + Task.FromException>( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableThemeSelectionService : IThemeSelectionService + { + private readonly string _message; + + public UnavailableThemeSelectionService(string message) + { + _message = message; + } + + public Task SearchAsync( + string query, + ThemeSession nxtSession, + int maximumResults = LegacyThemeSelectionService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task PreviewAsync( + ThemeSelectionIdentity identity, + int maximumItems = LegacyThemeSelectionService.DefaultMaximumPreviewItems, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + } + + private sealed class UnavailableExpertSelectionService : IExpertSelectionService + { + private readonly string _message; + + public UnavailableExpertSelectionService(string message) + { + _message = message; + } + + public Task SearchAsync( + string query = "", + int maximumResults = LegacyExpertSelectionService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + + public Task PreviewAsync( + ExpertSelectionIdentity identity, + int maximumItems = LegacyExpertSelectionService.DefaultMaximumPreviewItems, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + } + + private sealed class UnavailableTradingHaltSelectionService : ITradingHaltSelectionService + { + private readonly string _message; + + public UnavailableTradingHaltSelectionService(string message) + { + _message = message; + } + + public Task SearchAsync( + string query = "", + int maximumResults = LegacyTradingHaltSelectionService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableManualFinancialScreenService + : IManualFinancialScreenService + { + private readonly string _message; + + public UnavailableManualFinancialScreenService(string message) + { + _message = message; + } + + public bool CanMutate => false; + + public Task SearchAsync( + ManualFinancialScreenKind screen, + string query = "", + int maximumResults = LegacyManualFinancialScreenService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task GetAsync( + ManualFinancialIdentity identity, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task CreateAsync( + ManualFinancialRecord record, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task UpdateAsync( + ManualFinancialSnapshot expected, + ManualFinancialRecord replacement, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task DeleteAsync( + ManualFinancialSnapshot expected, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + + public Task DeleteAllAsync( + ManualFinancialScreenKind screen, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableStockSearchService : IStockSearchService + { + private readonly string _message; + + public UnavailableStockSearchService(string message) + { + _message = message; + } + + public Task SearchAsync( + string query, + int maximumResults = LegacyStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException(new InvalidOperationException(_message)); + } + + private sealed class UnavailableWorldStockSearchService : IWorldStockSearchService + { + private readonly string _message; + + public UnavailableWorldStockSearchService(string message) + { + _message = message; + } + + public Task SearchAsync( + string query, + int maximumResults = LegacyWorldStockSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } + + private sealed class UnavailableOverseasIndustryIndexSearchService + : IOverseasIndustryIndexSearchService + { + private readonly string _message; + + public UnavailableOverseasIndustryIndexSearchService(string message) + { + _message = message; + } + + public Task SearchAsync( + string query, + int maximumResults = LegacyOverseasIndustryIndexSearchService.DefaultMaximumResults, + CancellationToken cancellationToken = default) => + Task.FromException( + new InvalidOperationException(_message)); + } } diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js index 866e602..5581261 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/app.js @@ -8,16 +8,94 @@ const cutList = document.getElementById("cut-list"); const playlistRows = document.getElementById("playlist-rows"); const playlistDropZone = document.getElementById("playlist-drop-zone"); + const playlistMoveUp = document.getElementById("playlist-move-up"); + const playlistMoveDown = document.getElementById("playlist-move-down"); + const playlistDeleteSelected = document.getElementById("playlist-delete-selected"); + const playlistDeleteAll = document.getElementById("playlist-delete-all"); + const playlistEnableAll = document.getElementById("playlist-enable-all"); + const marketTabs = document.getElementById("market-tabs"); + const categoryTree = document.getElementById("category-tree"); + const catalogExpandAll = document.getElementById("catalog-expand-all"); + const movingAverage5 = document.getElementById("moving-average-5"); + const movingAverage20 = document.getElementById("moving-average-20"); const status = document.getElementById("operator-status"); const dialog = document.getElementById("legacy-dialog"); const dialogMessage = document.getElementById("legacy-dialog-message"); const dialogOk = document.getElementById("legacy-dialog-ok"); + const dbSaveButton = document.getElementById("db-save-button"); + const dbLoadButton = document.getElementById("db-load-button"); + const namedPlaylistModal = document.getElementById("named-playlist-modal"); + const namedPlaylistTitle = document.getElementById("named-playlist-title"); + const namedPlaylistClose = document.getElementById("named-playlist-close"); + const namedPlaylistStatus = document.getElementById("named-playlist-status"); + const namedPlaylistDefinitions = document.getElementById("named-playlist-definitions"); + const namedPlaylistNewTitle = document.getElementById("named-playlist-new-title"); + const namedPlaylistCreateButton = document.getElementById("named-playlist-create-button"); + const namedPlaylistDeleteButton = document.getElementById("named-playlist-delete-button"); + const namedPlaylistCancelButton = document.getElementById("named-playlist-cancel-button"); + const namedPlaylistConfirmButton = document.getElementById("named-playlist-confirm-button"); + const manualButtons = Array.from(document.querySelectorAll("button[data-manual-screen]")); + const manualModal = document.getElementById("manual-financial-modal"); + const manualTitle = document.getElementById("manual-financial-title"); + const manualClose = document.getElementById("manual-financial-close"); + const manualWorkspace = document.getElementById("manual-financial-workspace"); + const manualListButtons = Array.from( + document.querySelectorAll("button[data-manual-list-screen]")); + const manualListModal = document.getElementById("manual-list-modal"); + const manualListTitle = document.getElementById("manual-list-title"); + const manualListClose = document.getElementById("manual-list-close"); + const manualListWorkspace = document.getElementById("manual-list-workspace"); + const operatorCatalogModal = document.getElementById("operator-catalog-modal"); + const operatorCatalogTitle = document.getElementById("operator-catalog-title"); + const operatorCatalogClose = document.getElementById("operator-catalog-close"); + const operatorCatalogStatus = document.getElementById("operator-catalog-status"); + const operatorCatalogThemeTab = document.getElementById("operator-catalog-theme-tab"); + const operatorCatalogExpertTab = document.getElementById("operator-catalog-expert-tab"); + const operatorCatalogQuery = document.getElementById("operator-catalog-query"); + const operatorCatalogSession = document.getElementById("operator-catalog-session"); + const operatorCatalogSearch = document.getElementById("operator-catalog-search"); + const operatorCatalogNewMarket = document.getElementById("operator-catalog-new-market"); + const operatorCatalogNew = document.getElementById("operator-catalog-new"); + const operatorCatalogResults = document.getElementById("operator-catalog-results"); + const operatorCatalogEditor = document.getElementById("operator-catalog-editor"); + const operatorCatalogCode = document.getElementById("operator-catalog-code"); + const operatorCatalogNameLabel = document.getElementById("operator-catalog-name-label"); + const operatorCatalogName = document.getElementById("operator-catalog-name"); + const operatorCatalogStockQuery = document.getElementById("operator-catalog-stock-query"); + const operatorCatalogStockSearch = document.getElementById("operator-catalog-stock-search"); + const operatorCatalogStockResults = document.getElementById("operator-catalog-stock-results"); + const operatorCatalogAmountLabel = document.getElementById("operator-catalog-amount-label"); + const operatorCatalogAmount = document.getElementById("operator-catalog-amount"); + const operatorCatalogAddStock = document.getElementById("operator-catalog-add-stock"); + const operatorCatalogDraftRows = document.getElementById("operator-catalog-draft-rows"); + const operatorCatalogDelete = document.getElementById("operator-catalog-delete"); + const operatorCatalogCancel = document.getElementById("operator-catalog-cancel"); + const operatorCatalogSave = document.getElementById("operator-catalog-save"); let uiBusy = false; + let namedPlaylistMode = null; + let namedPlaylistState = null; + let manualDraft = null; + let manualDraftKey = ""; + let operatorCatalogState = null; + let operatorCatalogNameDraft = ""; + let operatorCatalogDraftKey = ""; + let manualResultClickTimer = null; + let themeResultClickTimer = null; + let comparisonTargetClickTimer = null; + let manualViResultClickTimer = null; + let operatorCatalogStockClickTimer = null; + let namedPlaylistResultClickTimer = null; + let pendingNamedPlaylistCommand = null; + let lastCommandSequence = 0; function send(type, payload) { if (webview && !uiBusy) webview.postMessage({ type: type, payload: payload || {} }); } + function clearDelayedClick(timer) { + if (timer !== null) window.clearTimeout(timer); + } + function createStockResultElement(row) { const button = document.createElement("button"); button.type = "button"; @@ -123,6 +201,8 @@ const element = document.createElement("div"); element.className = "playlist-row playlist-data-row"; element.dataset.rowId = row.rowId; + element.setAttribute("role", "option"); + element.tabIndex = -1; const enabledCell = document.createElement("div"); enabledCell.className = "enabled-cell"; @@ -140,24 +220,43 @@ for (let index = 0; index < 5; index += 1) { element.appendChild(document.createElement("div")); } + + element.addEventListener("pointerdown", function (event) { + if (event.button !== 0 || enabledCell.contains(event.target)) return; + element.focus({ preventScroll: true }); + send("select-playlist-row", { + rowId: element.dataset.rowId, + control: event.ctrlKey, + shift: event.shiftKey + }); + }); return element; } function renderPlaylist(state) { + const existingRows = new Map(); + Array.from(playlistRows.children).forEach(function (element) { + existingRows.set(element.dataset.rowId, element); + }); + state.playlist.forEach(function (row, position) { - let element = playlistRows.children.item(position); - if (!element || element.dataset.rowId !== row.rowId) { + let element = existingRows.get(row.rowId); + if (!element) { element = createPlaylistElement(row); - if (position < playlistRows.children.length) { - playlistRows.replaceChild(element, playlistRows.children.item(position)); - } else { - playlistRows.appendChild(element); - } + } + + const elementAtPosition = playlistRows.children.item(position); + if (elementAtPosition !== element) { + playlistRows.insertBefore(element, elementAtPosition); } const enabled = element.querySelector("input"); enabled.checked = row.isEnabled; + enabled.disabled = state.isBusy === true; enabled.setAttribute("aria-disabled", state.isBusy === true ? "true" : "false"); + element.className = "playlist-row playlist-data-row" + + (row.isSelected ? " selected" : ""); + element.setAttribute("aria-selected", row.isSelected ? "true" : "false"); [row.marketText, row.stockName, row.graphicType, row.subtype, row.pageText] .forEach(function (text, cellIndex) { const cell = element.children.item(cellIndex + 1); @@ -166,9 +265,1426 @@ }); }); - while (playlistRows.children.length > state.playlist.length) { - playlistRows.lastElementChild.remove(); + const retainedRowIds = new Set(state.playlist.map(function (row) { return row.rowId; })); + Array.from(playlistRows.children).forEach(function (element) { + if (!retainedRowIds.has(element.dataset.rowId)) element.remove(); + }); + + const interactionDisabled = state.isBusy === true; + const enabledCount = state.playlist.filter(function (row) { return row.isEnabled; }).length; + playlistEnableAll.checked = state.playlist.length === 0 || enabledCount === state.playlist.length; + playlistEnableAll.indeterminate = enabledCount > 0 && enabledCount < state.playlist.length; + playlistEnableAll.disabled = interactionDisabled; + playlistMoveUp.disabled = interactionDisabled; + playlistMoveDown.disabled = interactionDisabled; + playlistDeleteSelected.disabled = interactionDisabled; + playlistDeleteAll.disabled = interactionDisabled; + } + + function renderTabs(state) { + if (!Array.isArray(state.tabs)) return; + const existingTabs = new Map(); + Array.from(marketTabs.children).forEach(function (button) { + existingTabs.set(button.dataset.tabId, button); + }); + state.tabs.forEach(function (tab, position) { + const button = existingTabs.get(tab.id); + if (!button) return; + const buttonAtPosition = marketTabs.children.item(position); + if (buttonAtPosition !== button) marketTabs.insertBefore(button, buttonAtPosition); + button.textContent = tab.label; + button.classList.toggle("active", tab.isActive === true); + button.setAttribute("aria-current", tab.isActive === true ? "page" : "false"); + button.disabled = state.isBusy === true || state.fixedSectionBatch != null; + button.draggable = state.isBusy !== true && state.fixedSectionBatch == null; + }); + } + + function renderFixedCatalog(state) { + const catalog = state.fixedCatalog; + const batch = state.fixedSectionBatch; + if (!catalog || !Array.isArray(catalog.sections)) { + categoryTree.replaceChildren(); + return; } + + const fragment = document.createDocumentFragment(); + catalog.sections.forEach(function (section) { + const details = document.createElement("details"); + details.className = "catalog-section"; + details.open = catalogExpandAll.checked; + const summary = document.createElement("summary"); + summary.dataset.sectionIndex = String(section.index); + summary.textContent = "[" + section.name + "]"; + summary.title = batch + ? "현재 수동 입력 단계를 완료하거나 취소하세요." + : "더블클릭하면 섹션 전체를 원본 순서로 추가합니다."; + summary.setAttribute("aria-disabled", batch ? "true" : "false"); + const children = document.createElement("div"); + children.className = "catalog-actions"; + section.actions.forEach(function (action) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tree-item" + (action.isAvailable ? "" : " unavailable"); + button.dataset.actionId = action.id; + button.textContent = "▸ " + action.label; + button.title = action.isAvailable ? action.detail : action.prerequisite; + button.disabled = state.isBusy === true || batch != null; + children.appendChild(button); + }); + details.append(summary, children); + fragment.appendChild(details); + }); + categoryTree.replaceChildren(fragment); + } + + function renderIndustry(state) { + const industry = state.industry; + if (!industry || !Array.isArray(industry.industries) || !Array.isArray(industry.actions)) { + return; + } + + const workspace = document.createElement("div"); + workspace.className = "industry-workspace"; + const selector = document.createElement("section"); + selector.className = "industry-selector"; + const slots = document.createElement("div"); + slots.className = "industry-slots"; + const first = document.createElement("input"); + first.readOnly = true; + first.value = industry.firstName || ""; + first.setAttribute("aria-label", "첫 번째 업종"); + const swap = document.createElement("button"); + swap.type = "button"; + swap.dataset.industrySwap = "true"; + swap.textContent = "↕ 교환"; + const second = document.createElement("input"); + second.readOnly = true; + second.value = industry.secondName || ""; + second.setAttribute("aria-label", "두 번째 업종"); + slots.append(first, swap, second); + const list = document.createElement("div"); + list.className = "industry-list"; + list.setAttribute("role", "listbox"); + industry.industries.forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.industryIndex = String(row.index); + button.textContent = row.name; + button.className = industry.selectedIndex === row.index ? "selected" : ""; + button.setAttribute("aria-selected", industry.selectedIndex === row.index ? "true" : "false"); + list.appendChild(button); + }); + selector.append(slots, list); + + const actionTree = document.createElement("section"); + actionTree.className = "industry-action-tree"; + const sections = new Map(); + industry.actions.forEach(function (action) { + let group = sections.get(action.section); + if (!group) { + group = document.createElement("details"); + group.open = catalogExpandAll.checked; + const summary = document.createElement("summary"); + summary.textContent = "[" + action.section + "]"; + summary.dataset.industrySectionIndex = String(sections.size); + summary.title = "더블클릭하면 직계 자식 전체를 원본 순서로 추가합니다."; + group.appendChild(summary); + sections.set(action.section, group); + actionTree.appendChild(group); + } + const button = document.createElement("button"); + button.type = "button"; + button.className = "tree-item"; + button.dataset.industryActionId = action.actionId; + button.textContent = "▸ " + action.label; + group.appendChild(button); + }); + workspace.append(selector, actionTree); + categoryTree.replaceChildren(workspace); + } + + function renderOverseas(state) { + const overseas = state.overseas; + if (!overseas || !Array.isArray(overseas.fixedIndexTargets) || + !overseas.industry || !overseas.stock || !Array.isArray(overseas.actions)) { + return; + } + + const workspace = document.createElement("div"); + workspace.className = "overseas-workspace"; + + const fixedPanel = document.createElement("section"); + fixedPanel.className = "overseas-panel overseas-fixed-panel"; + const fixedTitle = document.createElement("h3"); + fixedTitle.textContent = "해외지수"; + const fixedList = document.createElement("div"); + fixedList.className = "overseas-fixed-list"; + fixedList.setAttribute("role", "listbox"); + overseas.fixedIndexTargets.forEach(function (target) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.overseasTargetId = target.targetId; + button.textContent = target.displayName; + button.className = overseas.selectedFixedIndexId === target.targetId ? "selected" : ""; + button.setAttribute( + "aria-selected", + overseas.selectedFixedIndexId === target.targetId ? "true" : "false"); + button.disabled = state.isBusy === true; + fixedList.appendChild(button); + }); + fixedPanel.append(fixedTitle, fixedList); + + function createSearchPanel(kind, title, model) { + const panel = document.createElement("section"); + panel.className = "overseas-panel overseas-search-panel"; + const heading = document.createElement("h3"); + heading.textContent = title; + const line = document.createElement("div"); + line.className = "overseas-search-line"; + const input = document.createElement("input"); + input.id = "overseas-" + kind + "-search"; + input.value = model.query || ""; + input.placeholder = title + " 검색"; + input.maxLength = 64; + input.readOnly = state.isBusy === true; + const search = document.createElement("button"); + search.type = "button"; + search.dataset.overseasSearch = kind; + search.textContent = "검색"; + search.disabled = state.isBusy === true; + line.append(input, search); + + const selected = document.createElement("div"); + selected.className = "overseas-selected"; + selected.textContent = model.selectedName ? "선택: " + model.selectedName : "선택 없음"; + + const results = document.createElement("div"); + results.className = "overseas-results"; + results.setAttribute("role", "listbox"); + (Array.isArray(model.results) ? model.results : []).forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.overseasResultKind = kind; + button.dataset.overseasSelectionId = row.selectionId; + button.textContent = row.nationCode + ? row.displayName + " (" + row.nationCode + ")" + : row.displayName; + button.className = model.selectedId === row.selectionId ? "selected" : ""; + button.setAttribute( + "aria-selected", + model.selectedId === row.selectionId ? "true" : "false"); + button.disabled = state.isBusy === true; + results.appendChild(button); + }); + + const note = document.createElement("div"); + note.className = model.error ? "overseas-note error" : "overseas-note"; + note.textContent = model.error || + (model.isTruncated ? "검색 결과 일부만 표시합니다." : model.resultCount + "건"); + panel.append(heading, line, selected, results, note); + return panel; + } + + const searches = document.createElement("div"); + searches.className = "overseas-searches"; + searches.append( + createSearchPanel("industry", "미국 해외업종", overseas.industry), + createSearchPanel("stock", "미국·대만 해외종목", overseas.stock)); + + const actionPanel = document.createElement("section"); + actionPanel.className = "overseas-panel overseas-action-panel"; + [ + ["fixedIndex", "해외지수 컷"], + ["industry", "해외업종 컷"], + ["stock", "해외종목 컷"] + ].forEach(function (groupDefinition) { + const actions = overseas.actions.filter(function (action) { + return action.source === groupDefinition[0]; + }); + const group = document.createElement("details"); + group.open = catalogExpandAll.checked; + const summary = document.createElement("summary"); + summary.textContent = "[" + groupDefinition[1] + "]"; + group.appendChild(summary); + actions.forEach(function (action) { + const button = document.createElement("button"); + button.type = "button"; + button.className = "tree-item" + (action.isAvailable ? "" : " unavailable"); + button.dataset.overseasActionId = action.actionId; + button.textContent = "▸ " + action.label; + button.title = action.isAvailable + ? "클릭하여 플레이리스트에 추가" + : "해당 지수·업종·종목을 먼저 선택하세요."; + button.disabled = !action.isAvailable || state.isBusy === true; + group.appendChild(button); + }); + actionPanel.appendChild(group); + }); + + workspace.append(fixedPanel, searches, actionPanel); + categoryTree.replaceChildren(workspace); + } + + function renderComparison(state) { + const comparison = state.comparison; + if (!comparison || !comparison.domesticSearch || !comparison.worldSearch) return; + + const workspace = document.createElement("div"); + workspace.className = "comparison-workspace"; + + const searches = document.createElement("section"); + searches.className = "comparison-searches"; + [ + ["domestic", "국내·NXT 종목", comparison.domesticSearch], + ["world", "미국·대만 종목", comparison.worldSearch] + ].forEach(function (definition) { + const kind = definition[0]; + const panel = document.createElement("div"); + panel.className = "comparison-search-panel"; + const line = document.createElement("div"); + line.className = "comparison-search-line"; + const input = document.createElement("input"); + input.id = "comparison-" + kind + "-search"; + input.maxLength = 64; + input.value = definition[2].query || ""; + input.placeholder = definition[1]; + const button = document.createElement("button"); + button.type = "button"; + button.dataset.comparisonSearch = kind; + button.textContent = "검색"; + line.append(input, button); + const results = document.createElement("div"); + results.className = "comparison-results"; + definition[2].results.forEach(function (row) { + const result = document.createElement("button"); + result.type = "button"; + result.dataset.comparisonChoice = kind; + result.dataset.comparisonSelectionId = row.selectionId; + result.className = definition[2].selectedResultId === row.selectionId + ? "selected" : ""; + result.setAttribute( + "aria-selected", + definition[2].selectedResultId === row.selectionId ? "true" : "false"); + result.textContent = row.displayName + " · " + row.metadata; + results.appendChild(result); + }); + panel.append(line, results); + searches.appendChild(panel); + }); + + const fixed = document.createElement("section"); + fixed.className = "comparison-fixed"; + const fixedTitle = document.createElement("strong"); + fixedTitle.textContent = "지수·환율 고정 대상"; + const fixedList = document.createElement("div"); + fixedList.className = "comparison-fixed-list"; + comparison.fixedTargets.forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.comparisonTargetId = row.targetId; + button.className = comparison.selectedFixedTargetId === row.targetId + ? "selected" : ""; + button.setAttribute( + "aria-selected", + comparison.selectedFixedTargetId === row.targetId ? "true" : "false"); + button.textContent = row.displayName; + fixedList.appendChild(button); + }); + fixed.append(fixedTitle, fixedList); + + const pairPanel = document.createElement("section"); + pairPanel.className = "comparison-pair-panel"; + const draft = document.createElement("div"); + draft.className = "comparison-draft"; + const first = document.createElement("input"); + first.readOnly = true; + first.value = comparison.firstTarget ? comparison.firstTarget.displayName : ""; + first.setAttribute("aria-label", "첫 번째 비교 대상"); + const second = document.createElement("input"); + second.readOnly = true; + second.value = comparison.secondTarget ? comparison.secondTarget.displayName : ""; + second.setAttribute("aria-label", "두 번째 비교 대상"); + const swap = document.createElement("button"); + swap.type = "button"; + swap.dataset.comparisonSwap = "true"; + swap.textContent = "↕ 교환"; + const clear = document.createElement("button"); + clear.type = "button"; + clear.dataset.comparisonClear = "true"; + clear.textContent = "초기화"; + const add = document.createElement("button"); + add.type = "button"; + add.dataset.comparisonAdd = "true"; + add.textContent = "비교쌍 저장"; + add.disabled = !comparison.firstTarget || !comparison.secondTarget; + draft.append(first, swap, second, clear, add); + + const saved = document.createElement("div"); + saved.className = "comparison-saved"; + comparison.savedPairs.forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.comparisonPairId = row.rowId; + button.textContent = row.rowNumber + ". " + row.displayLabel; + button.className = row.isSelected ? "selected" : ""; + saved.appendChild(button); + }); + const savedTools = document.createElement("div"); + savedTools.className = "comparison-saved-tools"; + [["up", "↑"], ["down", "↓"]].forEach(function (item) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.comparisonMove = item[0]; + button.textContent = item[1]; + savedTools.appendChild(button); + }); + const deleteSelected = document.createElement("button"); + deleteSelected.type = "button"; + deleteSelected.dataset.comparisonDelete = "selected"; + deleteSelected.textContent = "선택삭제"; + const deleteAll = document.createElement("button"); + deleteAll.type = "button"; + deleteAll.dataset.comparisonDelete = "all"; + deleteAll.textContent = "전체삭제"; + savedTools.append(deleteSelected, deleteAll); + pairPanel.append(draft, saved, savedTools); + + const actions = document.createElement("section"); + actions.className = "comparison-actions"; + const comparisonSections = new Map(); + comparison.actions.forEach(function (action) { + let group = comparisonSections.get(action.section); + if (!group) { + group = document.createElement("details"); + group.open = catalogExpandAll.checked; + const summary = document.createElement("summary"); + summary.textContent = "[" + action.section + "]"; + summary.dataset.comparisonSectionIndex = String(comparisonSections.size); + summary.title = "더블클릭하면 직계 자식 전체를 원본 순서로 추가합니다."; + group.appendChild(summary); + comparisonSections.set(action.section, group); + actions.appendChild(group); + } + const button = document.createElement("button"); + button.type = "button"; + button.dataset.comparisonActionId = action.actionId; + button.textContent = action.label; + button.disabled = !action.isAvailable; + button.title = action.unavailableReason || "더블클릭하여 플레이리스트에 추가"; + group.appendChild(button); + }); + + workspace.append(searches, fixed, pairPanel, actions); + categoryTree.replaceChildren(workspace); + } + + function renderTheme(state) { + const theme = state.theme; + if (!theme || !Array.isArray(theme.searchResults) || !Array.isArray(theme.actions)) { + return; + } + + const workspace = document.createElement("div"); + workspace.className = "theme-workspace"; + const search = document.createElement("div"); + search.className = "theme-search-line"; + const input = document.createElement("input"); + input.id = "theme-search"; + input.value = theme.query || ""; + input.maxLength = 64; + input.placeholder = "테마명"; + const session = document.createElement("select"); + session.id = "theme-nxt-session"; + [["preMarket", "NXT 장전"], ["afterMarket", "NXT 시간외"]].forEach(function (option) { + const element = document.createElement("option"); + element.value = option[0]; + element.textContent = option[1]; + session.appendChild(element); + }); + session.value = theme.nxtSession === "afterMarket" ? "afterMarket" : "preMarket"; + const searchButton = document.createElement("button"); + searchButton.type = "button"; + searchButton.dataset.themeSearch = "true"; + searchButton.textContent = "테마검색"; + const addTheme = document.createElement("button"); + addTheme.type = "button"; + addTheme.dataset.uc4ThemeCatalog = "create"; + addTheme.textContent = "테마추가"; + const editTheme = document.createElement("button"); + editTheme.type = "button"; + editTheme.dataset.uc4ThemeCatalog = "edit"; + editTheme.textContent = "테마편집"; + editTheme.disabled = !theme.selectedTheme; + const deleteTheme = document.createElement("button"); + deleteTheme.type = "button"; + deleteTheme.dataset.uc4ThemeCatalog = "delete"; + deleteTheme.textContent = "테마삭제"; + deleteTheme.disabled = !theme.selectedTheme; + search.append(input, session, searchButton, addTheme, editTheme, deleteTheme); + + const body = document.createElement("div"); + body.className = "theme-body"; + const results = document.createElement("div"); + results.className = "theme-results"; + results.setAttribute("role", "listbox"); + theme.searchResults.forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.themeResultId = row.resultId; + button.textContent = row.displayTitle; + button.className = theme.selectedResultId === row.resultId ? "selected" : ""; + results.appendChild(button); + }); + + const previewPanel = document.createElement("div"); + previewPanel.className = "theme-preview-panel"; + const selectedTitle = document.createElement("strong"); + selectedTitle.textContent = theme.selectedTheme + ? theme.selectedTheme.displayTitle + " · " + theme.selectedTheme.previewItemCount + "종목" + : "테마를 선택하세요."; + const sort = document.createElement("select"); + sort.dataset.themeSort = "true"; + theme.sorts.forEach(function (row) { + const option = document.createElement("option"); + option.value = row.id; + option.textContent = row.label; + sort.appendChild(option); + }); + sort.value = theme.sortId; + const preview = document.createElement("ol"); + preview.className = "theme-preview-list"; + theme.previewItems.forEach(function (row) { + const item = document.createElement("li"); + item.textContent = row.itemName; + preview.appendChild(item); + }); + previewPanel.append(selectedTitle, sort, preview); + body.append(results, previewPanel); + + const actions = document.createElement("div"); + actions.className = "theme-actions"; + theme.actions.forEach(function (action) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.themeActionId = action.actionId; + button.textContent = action.label; + button.disabled = !action.isAvailable; + button.title = action.unavailableReason || "더블클릭하여 플레이리스트에 추가"; + actions.appendChild(button); + }); + workspace.append(search, body, actions); + categoryTree.replaceChildren(workspace); + } + + function renderExpert(state) { + const expert = state.expert; + if (!expert || !expert.search || !expert.preview) return; + const workspace = document.createElement("div"); + workspace.className = "expert-workspace"; + const search = document.createElement("div"); + search.className = "expert-search-line"; + const input = document.createElement("input"); + input.id = "expert-search"; + input.maxLength = 64; + input.value = expert.search.query || ""; + input.placeholder = "전문가명"; + const searchButton = document.createElement("button"); + searchButton.type = "button"; + searchButton.dataset.expertSearch = "true"; + searchButton.textContent = "조회"; + const addExpert = document.createElement("button"); + addExpert.type = "button"; + addExpert.dataset.uc6ExpertCatalog = "create"; + addExpert.textContent = "전문가 추가"; + const deleteExpert = document.createElement("button"); + deleteExpert.type = "button"; + deleteExpert.dataset.uc6ExpertCatalog = "delete"; + deleteExpert.textContent = "전문가 삭제"; + deleteExpert.disabled = !expert.selectedExpert; + search.append(input, searchButton, addExpert, deleteExpert); + + const body = document.createElement("div"); + body.className = "expert-body"; + const results = document.createElement("div"); + results.className = "expert-results"; + expert.search.results.forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.expertSelectionId = row.selectionId; + button.textContent = row.expertName; + button.className = expert.selectedExpert && + expert.selectedExpert.selectionId === row.selectionId ? "selected" : ""; + results.appendChild(button); + }); + const preview = document.createElement("div"); + preview.className = "expert-preview"; + const heading = document.createElement("strong"); + heading.textContent = expert.selectedExpert + ? expert.selectedExpert.expertName + " 추천 종목" + : "전문가를 선택하세요."; + const list = document.createElement("ol"); + expert.preview.items.forEach(function (row) { + const item = document.createElement("li"); + item.textContent = row.stockName + " · " + row.buyAmount; + list.appendChild(item); + }); + preview.append(heading, list); + body.append(results, preview); + + const actions = document.createElement("div"); + actions.className = "expert-actions"; + expert.actions.forEach(function (action) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.expertActionId = action.actionId; + button.textContent = action.label; + button.disabled = !action.isAvailable; + actions.appendChild(button); + }); + const editRecommendations = document.createElement("button"); + editRecommendations.type = "button"; + editRecommendations.dataset.uc6ExpertCatalog = "edit"; + editRecommendations.textContent = "추천행 편집·저장·삭제"; + editRecommendations.disabled = !expert.selectedExpert; + actions.appendChild(editRecommendations); + workspace.append(search, body, actions); + categoryTree.replaceChildren(workspace); + } + + function renderTradingHalt(state) { + const halt = state.tradingHalt; + if (!halt || !Array.isArray(halt.results)) return; + const workspace = document.createElement("div"); + workspace.className = "halt-workspace"; + const search = document.createElement("div"); + search.className = "halt-search-line"; + const input = document.createElement("input"); + input.id = "halt-search"; + input.maxLength = 64; + input.value = halt.query || ""; + input.placeholder = "거래정지 종목"; + const searchButton = document.createElement("button"); + searchButton.type = "button"; + searchButton.dataset.haltSearch = "true"; + searchButton.textContent = "검색"; + const sortButton = document.createElement("button"); + sortButton.type = "button"; + sortButton.dataset.haltSort = "true"; + sortButton.textContent = halt.nameSort === "ascending" ? "종목명 ↑" : "종목명 ↓"; + search.append(input, searchButton, sortButton); + const results = document.createElement("div"); + results.className = "halt-results"; + halt.results.forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.haltSelectionId = row.selectionId; + button.textContent = (row.market === "kospi" ? "코스피 · " : "코스닥 · ") + row.displayName; + button.className = row.isSelected ? "selected" : ""; + results.appendChild(button); + }); + const actions = document.createElement("div"); + actions.className = "halt-actions"; + halt.actions.forEach(function (action) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.haltActionId = action.actionId; + button.textContent = action.label; + button.disabled = !action.isAvailable; + actions.appendChild(button); + }); + workspace.append(search, results, actions); + categoryTree.replaceChildren(workspace); + } + + function blankManualRecord(screen) { + if (screen === "revenue-composition") { + return { + screen: screen, + stockName: "", + baseDate: "", + slices: Array.from({ length: 5 }, function () { + return { label: "", percentageText: "" }; + }) + }; + } + if (screen === "growth-metrics") { + return { + screen: screen, + stockName: "", + salesGrowth: [null, null, null, null], + operatingProfitGrowth: [null, null, null, null], + netAssetGrowth: [null, null, null, null], + netIncomeGrowth: [null, null, null, null], + periods: ["", "", "", ""] + }; + } + return { + screen: screen, + stockName: "", + quarters: Array.from({ length: 6 }, function () { + return { quarter: "", value: 0 }; + }) + }; + } + + function cloneManualRecord(record) { + return record ? JSON.parse(JSON.stringify(record)) : null; + } + + function manualElement(tagName, className, text) { + const element = document.createElement(tagName); + if (className) element.className = className; + if (text !== undefined) element.textContent = text; + return element; + } + + function manualInput(value, dataName, dataValue, type) { + const input = document.createElement("input"); + input.type = type || "text"; + input.value = value === null || value === undefined ? "" : String(value); + if (dataName) input.dataset[dataName] = String(dataValue); + return input; + } + + function appendManualLabel(parent, labelText, input) { + const label = manualElement("label", "manual-financial-field"); + label.append(manualElement("span", "", labelText), input); + parent.appendChild(label); + } + + function renderManualEditorFields(editor, record) { + const fields = manualElement("div", "manual-financial-fields"); + if (record.screen === "revenue-composition") { + appendManualLabel( + fields, + "기준일", + manualInput(record.baseDate, "manualBaseDate", "true")); + const slices = manualElement("div", "manual-financial-grid manual-revenue-grid"); + slices.append( + manualElement("strong", "", "구성"), + manualElement("strong", "", "비율")); + record.slices.forEach(function (slice, index) { + slices.append( + manualInput(slice ? slice.label : "", "manualRevenueLabel", index), + manualInput( + slice ? slice.percentageText : "", + "manualRevenuePercentage", + index)); + }); + fields.appendChild(slices); + } else if (record.screen === "growth-metrics") { + const grid = manualElement("div", "manual-financial-grid manual-growth-grid"); + grid.appendChild(manualElement("strong", "", "구분")); + record.periods.forEach(function (period, index) { + grid.appendChild(manualInput(period, "manualGrowthPeriod", index)); + }); + [ + ["매출 성장", "salesGrowth"], + ["영업이익 성장", "operatingProfitGrowth"], + ["순자산 성장", "netAssetGrowth"], + ["순이익 성장", "netIncomeGrowth"] + ].forEach(function (series) { + grid.appendChild(manualElement("span", "", series[0])); + record[series[1]].forEach(function (value, index) { + const input = manualInput(value, "manualGrowthValue", index, "number"); + input.dataset.manualGrowthSeries = series[1]; + input.step = "any"; + grid.appendChild(input); + }); + }); + fields.appendChild(grid); + } else { + const grid = manualElement("div", "manual-financial-grid manual-quarter-grid"); + grid.append( + manualElement("strong", "", "분기"), + manualElement("strong", "", "값")); + record.quarters.forEach(function (quarter, index) { + grid.append( + manualInput(quarter.quarter, "manualQuarterLabel", index), + manualInput(quarter.value, "manualQuarterValue", index, "number")); + }); + fields.appendChild(grid); + } + editor.appendChild(fields); + } + + function readManualRecord() { + if (!manualDraft || !manualWorkspace) return null; + const stockName = manualWorkspace.querySelector("#manual-financial-stock-name"); + const record = blankManualRecord(manualDraft.screen); + record.stockName = stockName ? stockName.value.trim() : ""; + if (record.screen === "revenue-composition") { + const baseDate = manualWorkspace.querySelector("[data-manual-base-date]"); + record.baseDate = baseDate ? baseDate.value : ""; + record.slices = record.slices.map(function (_, index) { + const label = manualWorkspace.querySelector( + "[data-manual-revenue-label=\"" + index + "\"]"); + const percentage = manualWorkspace.querySelector( + "[data-manual-revenue-percentage=\"" + index + "\"]"); + const labelValue = label ? label.value.trim() : ""; + const percentageValue = percentage ? percentage.value.trim() : ""; + return labelValue === "" && percentageValue === "" ? null : { + label: labelValue, + percentageText: percentageValue + }; + }); + } else if (record.screen === "growth-metrics") { + record.periods = record.periods.map(function (_, index) { + const input = manualWorkspace.querySelector( + "[data-manual-growth-period=\"" + index + "\"]"); + return input ? input.value : ""; + }); + ["salesGrowth", "operatingProfitGrowth", "netAssetGrowth", "netIncomeGrowth"] + .forEach(function (seriesName) { + record[seriesName] = record[seriesName].map(function (_, index) { + const input = manualWorkspace.querySelector( + "[data-manual-growth-series=\"" + seriesName + "\"]" + + "[data-manual-growth-value=\"" + index + "\"]"); + if (!input || input.value.trim() === "") return null; + const value = Number(input.value); + return Number.isFinite(value) ? value : null; + }); + }); + } else { + record.quarters = record.quarters.map(function (_, index) { + const quarter = manualWorkspace.querySelector( + "[data-manual-quarter-label=\"" + index + "\"]"); + const value = manualWorkspace.querySelector( + "[data-manual-quarter-value=\"" + index + "\"]"); + const parsed = value && /^-?\d+$/.test(value.value.trim()) + ? Number(value.value) : null; + return { + quarter: quarter ? quarter.value.trim() : "", + value: parsed + }; + }); + } + return record; + } + + function rememberManualDraft() { + const record = readManualRecord(); + if (record) manualDraft = record; + } + + function renderManualFinancial(state) { + const manual = state.manualFinancial; + manualButtons.forEach(function (button) { + button.disabled = state.isBusy === true; + }); + if (!manual) { + manualModal.hidden = true; + manualWorkspace.replaceChildren(); + manualDraft = null; + manualDraftKey = ""; + return; + } + + manualModal.hidden = false; + manualTitle.textContent = manual.label + " · GraphE 수동 입력"; + const detailKey = manual.screen + "|" + (manual.selectedRowId || "new") + "|" + + JSON.stringify(manual.selectedRecord || null); + if (manual.selectedRecord && detailKey !== manualDraftKey) { + manualDraft = cloneManualRecord(manual.selectedRecord); + manualDraftKey = detailKey; + } else if (!manualDraft || manualDraft.screen !== manual.screen) { + manualDraft = blankManualRecord(manual.screen); + manualDraftKey = manual.screen + "|new"; + } + if (!manual.selectedRecord && manual.verifiedStock) { + manualDraft.stockName = manual.verifiedStock.name; + } + + const toolbar = manualElement("div", "manual-financial-toolbar"); + const listQuery = manualInput(manual.query || "", "manualListQuery", "true"); + listQuery.id = "manual-financial-list-query"; + const listSearch = manualElement("button", "", "목록 검색"); + listSearch.type = "button"; + listSearch.dataset.manualListSearch = "true"; + const findQuery = manualInput(manual.findQuery || "", "manualFindQuery", "true"); + findQuery.id = "manual-financial-find-query"; + findQuery.placeholder = "종목명 찾기"; + const findUp = manualElement("button", "", "▲ 위로 찾기"); + findUp.type = "button"; + findUp.dataset.manualFindDirection = "up"; + findUp.dataset.manualGeneration = String(manual.generation); + const findDown = manualElement("button", "", "▼ 아래로 찾기"); + findDown.type = "button"; + findDown.dataset.manualFindDirection = "down"; + findDown.dataset.manualGeneration = String(manual.generation); + const create = manualElement("button", "", "새 입력"); + create.type = "button"; + create.dataset.manualCreate = "true"; + toolbar.append(listQuery, listSearch, create, findQuery, findUp, findDown); + + const body = manualElement("div", "manual-financial-body"); + const list = manualElement("div", "manual-financial-list"); + const nameHeader = manualElement( + "button", + "manual-financial-list-header", + "종목명 " + (manual.nameSortDirection === "descending" ? "▼" : "▲")); + nameHeader.type = "button"; + nameHeader.dataset.manualNameSort = "true"; + nameHeader.dataset.manualGeneration = String(manual.generation); + nameHeader.title = "종목명 정렬 방향 전환"; + list.appendChild(nameHeader); + manual.rows.forEach(function (row) { + const button = manualElement("button", "", row.stockName); + button.type = "button"; + button.dataset.manualResultId = row.resultId; + button.className = manual.selectedRowId === row.resultId ? "selected" : ""; + list.appendChild(button); + }); + + const editor = manualElement("section", "manual-financial-editor"); + const stockLine = manualElement("div", "manual-financial-stock-line"); + const stockInput = manualInput(manualDraft.stockName || ""); + stockInput.id = "manual-financial-stock-name"; + stockInput.readOnly = !!manual.selectedRecord; + const stockSearch = manualElement("button", "", "종목 확인"); + stockSearch.type = "button"; + stockSearch.dataset.manualStockSearch = "true"; + stockLine.append(stockInput, stockSearch); + editor.appendChild(stockLine); + + const candidates = manualElement("div", "manual-financial-stock-results"); + manual.stockCandidates.forEach(function (row) { + const button = manualElement( + "button", + manual.selectedStockResultId === row.resultId ? "selected" : "", + row.name + " · " + row.market); + button.type = "button"; + button.dataset.manualStockResultId = row.resultId; + candidates.appendChild(button); + }); + editor.appendChild(candidates); + const verification = manualElement( + "div", + "manual-financial-verification", + manual.verifiedStock + ? "확인됨: " + manual.verifiedStock.name + " · " + manual.verifiedStock.market + : "정확한 종목 마스터를 확인하세요."); + editor.appendChild(verification); + renderManualEditorFields(editor, manualDraft); + + const actions = manualElement("div", "manual-financial-actions"); + const save = manualElement("button", "manual-primary", "저장"); + save.type = "button"; + save.dataset.manualSave = "true"; + save.disabled = !manual.canSave; + const remove = manualElement("button", "", "선택 삭제"); + remove.type = "button"; + remove.dataset.manualDelete = "true"; + remove.disabled = !manual.canDelete; + const removeAll = manualElement("button", "", "전체 삭제"); + removeAll.type = "button"; + removeAll.dataset.manualDeleteAll = "true"; + removeAll.disabled = manual.writesQuarantined === true; + const add = manualElement("button", "manual-primary", "편성표에 추가"); + add.type = "button"; + add.dataset.manualActionId = manual.action.actionId; + add.disabled = !manual.canMaterializePlaylist; + actions.append(save, remove, removeAll, add); + editor.appendChild(actions); + if (manual.lastMessage) { + editor.appendChild(manualElement( + "div", + manual.writesQuarantined ? "manual-financial-warning" : "manual-financial-message", + manual.lastMessage)); + } + + body.append(list, editor); + manualWorkspace.replaceChildren(toolbar, body); + manualClose.disabled = state.isBusy === true; + if (state.isBusy === true) { + manualWorkspace.querySelectorAll("button, input").forEach(function (control) { + control.disabled = true; + }); + } + } + + function isCanonicalNamedPlaylistTitle(value) { + return value.length > 0 && value.length <= 128 && value === value.trim() && + !/[\u0000-\u001f\u007f-\u009f\ud800-\udfff\ufffd]/.test(value); + } + + function renderManualLists(state) { + const manual = state.manualLists; + const fixedBatch = state.fixedSectionBatch; + manualListButtons.forEach(function (button) { + button.disabled = state.isBusy === true || fixedBatch != null; + }); + if (!manual || manual.isOpen !== true) { + clearDelayedClick(manualViResultClickTimer); + manualViResultClickTimer = null; + manualListModal.hidden = true; + manualListWorkspace.replaceChildren(); + return; + } + + manualListModal.hidden = false; + const busy = state.isBusy === true; + if (manual.screen !== "vi") { + clearDelayedClick(manualViResultClickTimer); + manualViResultClickTimer = null; + } + const audienceLabels = { + individual: "개인", + foreign: "외국인", + institution: "기관" + }; + const baseTitle = manual.screen === "vi" + ? "VI 발동 수동 목록" + : (audienceLabels[manual.audience] || "수동") + " 순매도 상위"; + manualListTitle.textContent = fixedBatch + ? baseTitle + " · " + fixedBatch.sectionName + " " + + String(fixedBatch.manualStepNumber) + "/" + String(fixedBatch.manualStepCount) + : baseTitle; + + const fragment = document.createDocumentFragment(); + if (fixedBatch) { + const progress = manualElement( + "div", + "manual-list-status", + "섹션 전체 " + String(fixedBatch.totalActionCount) + "개는 아직 편성표에 추가되지 않았습니다. " + + "현재 항목을 확인하면 C#이 다음 수동 항목으로 진행하며, 닫으면 배치를 취소합니다."); + progress.setAttribute("role", "status"); + fragment.appendChild(progress); + } + if (manual.writesQuarantined || manual.isAvailable !== true) { + const warning = manualElement( + "div", + "manual-list-status error", + manual.statusMessage || manual.availabilityMessage || + "수동 목록 저장소를 사용할 수 없습니다."); + warning.setAttribute("role", "alert"); + fragment.appendChild(warning); + } + + if (manual.screen === "net-sell") { + const tabs = manualElement("div", "manual-list-tabs"); + Object.keys(audienceLabels).forEach(function (audience) { + const button = manualElement( + "button", + audience === manual.audience ? "active" : "", + audienceLabels[audience]); + button.type = "button"; + button.disabled = busy || fixedBatch != null; + button.addEventListener("click", function () { + send("open-manual-list", { screen: audience }); + }); + tabs.appendChild(button); + }); + fragment.appendChild(tabs); + + const wrap = manualElement("div", "manual-list-table-wrap"); + const table = manualElement("table", "manual-list-table"); + const head = document.createElement("thead"); + const headRow = document.createElement("tr"); + ["#", "좌측 종목", "좌측 금액", "우측 종목", "우측 금액"].forEach(function (label) { + headRow.appendChild(manualElement("th", "", label)); + }); + head.appendChild(headRow); + table.appendChild(head); + const body = document.createElement("tbody"); + const fields = ["leftName", "leftAmount", "rightName", "rightAmount"]; + manual.netRows.forEach(function (row, index) { + const tr = document.createElement("tr"); + tr.appendChild(manualElement("th", "", String(index + 1))); + fields.forEach(function (field) { + const td = document.createElement("td"); + const input = document.createElement("input"); + input.type = "text"; + input.maxLength = 256; + input.value = row[field] || ""; + input.disabled = busy || manual.isAvailable !== true || manual.writesQuarantined; + input.addEventListener("change", function () { + manualListWorkspace.querySelectorAll("button, input").forEach(function (control) { + control.disabled = true; + }); + send("set-manual-net-sell-cell", { + rowId: row.rowId, + field: field, + value: input.value + }); + }); + td.appendChild(input); + tr.appendChild(td); + }); + body.appendChild(tr); + }); + table.appendChild(body); + wrap.appendChild(table); + fragment.appendChild(wrap); + } else { + const search = manualElement("div", "manual-list-search"); + const input = document.createElement("input"); + input.type = "search"; + input.maxLength = 100; + input.placeholder = "종목명 검색"; + input.value = manual.searchQuery || ""; + const searchButton = manualElement("button", "", "검색"); + searchButton.type = "button"; + searchButton.disabled = busy || manual.isAvailable !== true; + function submitSearch() { + if (input.value.trim()) send("search-manual-vi", { query: input.value }); + } + searchButton.addEventListener("click", submitSearch); + input.addEventListener("keydown", function (event) { + if (event.key === "Enter") { + event.preventDefault(); + submitSearch(); + } + }); + search.append(input, searchButton); + fragment.appendChild(search); + + const results = manualElement("div", "manual-list-search-results"); + manual.searchResults.forEach(function (row) { + const button = manualElement( + "button", + manual.selectedSearchResultId === row.resultId ? "selected" : "", + row.displayName + " · " + row.marketText); + button.type = "button"; + button.disabled = busy; + button.addEventListener("click", function () { + clearDelayedClick(manualViResultClickTimer); + manualViResultClickTimer = window.setTimeout(function () { + manualViResultClickTimer = null; + send("select-manual-vi-result", { resultId: row.resultId }); + }, 250); + }); + button.addEventListener("dblclick", function (event) { + event.preventDefault(); + clearDelayedClick(manualViResultClickTimer); + manualViResultClickTimer = null; + send("add-manual-vi-result", { resultId: row.resultId }); + }); + results.appendChild(button); + }); + fragment.appendChild(results); + + const summary = manualElement( + "div", + "manual-list-status", + String(manual.viItems.length) + "/100종목 · " + + String(manual.viPageCount) + "페이지"); + fragment.appendChild(summary); + const wrap = manualElement("div", "manual-list-table-wrap"); + const table = manualElement("table", "manual-list-table"); + const body = document.createElement("tbody"); + manual.viItems.forEach(function (row, index) { + const tr = document.createElement("tr"); + tr.append( + manualElement("td", "", String(row.index)), + manualElement("td", "", row.name)); + const controls = manualElement("td", "manual-list-row-actions"); + [["↑", "up"], ["↓", "down"]].forEach(function (move) { + const button = manualElement("button", "", move[0]); + button.type = "button"; + button.disabled = busy || (move[1] === "up" ? index === 0 : index === manual.viItems.length - 1); + button.addEventListener("click", function () { + send("move-manual-vi-item", { itemId: row.itemId, direction: move[1] }); + }); + controls.appendChild(button); + }); + const remove = manualElement("button", "", "삭제"); + remove.type = "button"; + remove.disabled = busy; + remove.addEventListener("click", function () { + send("delete-manual-vi-item", { itemId: row.itemId }); + }); + controls.appendChild(remove); + tr.appendChild(controls); + body.appendChild(tr); + }); + table.appendChild(body); + wrap.appendChild(table); + fragment.appendChild(wrap); + } + + if (manual.statusMessage) { + fragment.appendChild(manualElement( + "div", + "manual-list-status" + + (manual.statusKind === "error" || manual.writesQuarantined ? " error" : ""), + manual.statusMessage)); + } + const actions = manualElement("div", "manual-list-actions-row"); + const importButton = manualElement("button", "", "원본 1회 가져오기"); + importButton.type = "button"; + importButton.disabled = busy || manual.isAvailable !== true || manual.writesQuarantined; + importButton.addEventListener("click", function () { + if (window.confirm("원본 파일을 현재 빈 저장소로 한 번만 가져옵니다. 기존 파일은 덮어쓰지 않습니다.")) { + send("import-manual-lists", {}); + } + }); + const refresh = manualElement("button", "", "재조회"); + refresh.type = "button"; + refresh.disabled = busy || manual.isAvailable !== true; + refresh.addEventListener("click", function () { send("refresh-manual-list", {}); }); + if (manual.screen === "vi") { + const clear = manualElement("button", "", "전체 삭제"); + clear.type = "button"; + clear.disabled = busy || manual.viItems.length === 0; + clear.addEventListener("click", function () { + if (window.confirm("VI 목록을 모두 지우시겠습니까?")) { + send("delete-all-manual-vi-items", {}); + } + }); + actions.appendChild(clear); + } + const confirm = manualElement("button", "primary", "확인"); + confirm.type = "button"; + if (fixedBatch) { + confirm.textContent = fixedBatch.manualStepNumber < fixedBatch.manualStepCount + ? "확인 후 다음" + : "확인 후 섹션 추가"; + } + confirm.disabled = busy || manual.canSave !== true; + confirm.addEventListener("click", function () { + send("confirm-manual-list", {}); + }); + actions.append(importButton, refresh, confirm); + fragment.appendChild(actions); + manualListWorkspace.replaceChildren(fragment); + manualListClose.disabled = busy; + } + + function openNamedPlaylist(mode) { + namedPlaylistMode = mode; + namedPlaylistTitle.textContent = mode === "save" + ? "DB 재생목록 저장" : "DB 재생목록 불러오기"; + namedPlaylistConfirmButton.textContent = mode === "save" ? "현재 목록 저장" : "불러오기"; + namedPlaylistNewTitle.value = ""; + namedPlaylistModal.hidden = false; + if (namedPlaylistState) { + renderNamedPlaylist({ namedPlaylist: namedPlaylistState, isBusy: uiBusy }); + } + send("refresh-named-playlists", {}); + } + + function closeNamedPlaylist() { + clearDelayedClick(namedPlaylistResultClickTimer); + namedPlaylistResultClickTimer = null; + pendingNamedPlaylistCommand = null; + namedPlaylistMode = null; + namedPlaylistModal.hidden = true; + } + + function renderNamedPlaylist(state) { + const named = state.namedPlaylist; + namedPlaylistState = named || null; + const busy = state.isBusy === true; + const commandResult = state.commandResult; + if (commandResult && Number.isSafeInteger(commandResult.sequence)) { + lastCommandSequence = Math.max(lastCommandSequence, commandResult.sequence); + } + if (!busy && pendingNamedPlaylistCommand && commandResult && + commandResult.sequence >= pendingNamedPlaylistCommand.minimumSequence && + commandResult.command === pendingNamedPlaylistCommand.command && + commandResult.targetId === pendingNamedPlaylistCommand.definitionId) { + const succeeded = commandResult.succeeded === true; + pendingNamedPlaylistCommand = null; + if (succeeded) { + closeNamedPlaylist(); + } + } + dbLoadButton.disabled = busy || !named; + dbSaveButton.disabled = busy || !named || named.canMutate !== true; + if (!named || namedPlaylistModal.hidden) return; + + const fragment = document.createDocumentFragment(); + named.definitions.forEach(function (definition) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.namedPlaylistDefinitionId = definition.definitionId; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", definition.isSelected ? "true" : "false"); + button.className = definition.isSelected ? "selected" : ""; + button.disabled = busy || named.isWriteInProgress === true; + button.textContent = definition.title; + fragment.appendChild(button); + }); + namedPlaylistDefinitions.replaceChildren(fragment); + + const selectedDescription = named.selectedTitle + ? "선택: " + named.selectedTitle + " · " + String(named.rowCount) + "행" + : "DB 재생목록을 선택하세요."; + const quarantine = named.isWriteQuarantined === true + ? " 결과 불명확 상태로 이번 실행의 추가 쓰기가 차단되었습니다." : ""; + const truncated = named.isListTruncated === true + ? " 목록 일부만 표시 중입니다." : ""; + namedPlaylistStatus.textContent = (named.statusMessage || selectedDescription) + + quarantine + truncated; + namedPlaylistStatus.classList.toggle("error", named.isWriteQuarantined === true || + named.statusKind === "error"); + + namedPlaylistCreateButton.disabled = busy || named.canCreate !== true || + !isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value); + namedPlaylistDeleteButton.disabled = busy || named.canDelete !== true; + namedPlaylistConfirmButton.disabled = busy || + (namedPlaylistMode === "save" ? named.canSave !== true : named.canLoad !== true); + } + + function renderOperatorCatalog(state) { + const catalog = state.operatorCatalog; + const busy = state.isBusy === true; + operatorCatalogState = catalog || null; + if (!catalog || catalog.isOpen !== true) { + clearDelayedClick(operatorCatalogStockClickTimer); + operatorCatalogStockClickTimer = null; + operatorCatalogModal.hidden = true; + operatorCatalogDraftKey = ""; + operatorCatalogNameDraft = ""; + return; + } + + operatorCatalogModal.hidden = false; + const isTheme = catalog.entity === "theme"; + const editing = catalog.mode === "create" || catalog.mode === "edit"; + operatorCatalogTitle.textContent = isTheme ? "ThemeA 관리" : "EList 관리"; + operatorCatalogThemeTab.classList.toggle("selected", isTheme); + operatorCatalogExpertTab.classList.toggle("selected", !isTheme); + operatorCatalogThemeTab.disabled = busy; + operatorCatalogExpertTab.disabled = busy; + operatorCatalogClose.disabled = busy; + operatorCatalogSearch.disabled = busy; + operatorCatalogSession.disabled = busy || !isTheme; + operatorCatalogSession.hidden = !isTheme; + operatorCatalogNewMarket.disabled = busy || !isTheme; + operatorCatalogNewMarket.hidden = !isTheme; + operatorCatalogNew.disabled = busy || catalog.canBeginCreate !== true; + operatorCatalogNew.textContent = isTheme ? "새 ThemeA" : "새 EList"; + if (document.activeElement !== operatorCatalogQuery) { + operatorCatalogQuery.value = catalog.query || ""; + } + operatorCatalogSession.value = catalog.nxtSession === "afterMarket" + ? "afterMarket" : "preMarket"; + operatorCatalogNewMarket.value = catalog.newThemeMarket === "nxt" ? "nxt" : "krx"; + + const resultFragment = document.createDocumentFragment(); + (catalog.results || []).forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.operatorCatalogResultId = row.resultId; + button.className = row.isSelected ? "selected" : ""; + button.disabled = busy; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", row.isSelected ? "true" : "false"); + const code = document.createElement("strong"); + code.textContent = row.codeLabel; + const name = document.createElement("span"); + name.textContent = row.displayName; + const scope = document.createElement("small"); + scope.textContent = row.scope; + button.append(code, name, scope); + resultFragment.appendChild(button); + }); + operatorCatalogResults.replaceChildren(resultFragment); + + operatorCatalogEditor.hidden = !editing; + const draftKey = catalog.entity + "|" + catalog.mode + "|" + + (catalog.selectedResultId || "new") + "|" + (catalog.codeLabel || ""); + if (draftKey !== operatorCatalogDraftKey) { + operatorCatalogDraftKey = draftKey; + operatorCatalogNameDraft = catalog.editorName || ""; + } + if (document.activeElement !== operatorCatalogName) { + operatorCatalogName.value = operatorCatalogNameDraft; + } + operatorCatalogCode.textContent = catalog.codeLabel + ? "코드 " + catalog.codeLabel : ""; + operatorCatalogNameLabel.textContent = isTheme ? "테마명" : "전문가명"; + operatorCatalogName.placeholder = isTheme ? "ThemeA 이름" : "전문가 이름"; + operatorCatalogName.disabled = busy || !editing; + if (document.activeElement !== operatorCatalogStockQuery) { + operatorCatalogStockQuery.value = catalog.stockQuery || ""; + } + operatorCatalogStockQuery.disabled = busy || !editing; + operatorCatalogStockSearch.disabled = busy || !editing; + operatorCatalogAmountLabel.hidden = isTheme; + operatorCatalogAmount.hidden = isTheme; + operatorCatalogAmount.disabled = busy || !editing || isTheme; + operatorCatalogAddStock.disabled = busy || catalog.canAddStock !== true; + + const stockFragment = document.createDocumentFragment(); + (catalog.stockResults || []).forEach(function (row) { + const button = document.createElement("button"); + button.type = "button"; + button.dataset.operatorCatalogStockResultId = row.resultId; + button.className = row.isSelected ? "selected" : ""; + button.disabled = busy || row.isCompatible !== true; + button.title = "더블클릭하면 구성 종목에 추가합니다."; + button.setAttribute("role", "option"); + button.setAttribute("aria-selected", row.isSelected ? "true" : "false"); + const name = document.createElement("span"); + name.textContent = row.displayName; + const market = document.createElement("small"); + market.textContent = row.market; + button.append(name, market); + stockFragment.appendChild(button); + }); + operatorCatalogStockResults.replaceChildren(stockFragment); + + const draftFragment = document.createDocumentFragment(); + (catalog.draftRows || []).forEach(function (row, index) { + const line = document.createElement("div"); + line.className = "operator-catalog-draft-row"; + const order = document.createElement("span"); + order.textContent = String(row.position + 1); + const name = document.createElement("strong"); + name.textContent = row.displayName; + const detail = document.createElement(isTheme ? "small" : "input"); + if (isTheme) { + detail.textContent = row.market; + } else { + detail.type = "number"; + detail.min = "1"; + detail.step = "1"; + detail.inputMode = "numeric"; + detail.value = String(row.buyAmount); + detail.dataset.operatorCatalogBuyAmountRowId = row.rowId; + detail.setAttribute("aria-label", row.displayName + " 매수가"); + detail.disabled = busy; + } + const up = document.createElement("button"); + up.type = "button"; + up.textContent = "↑"; + up.dataset.operatorCatalogMoveRowId = row.rowId; + up.dataset.operatorCatalogMoveDirection = "up"; + up.disabled = busy || index === 0; + const down = document.createElement("button"); + down.type = "button"; + down.textContent = "↓"; + down.dataset.operatorCatalogMoveRowId = row.rowId; + down.dataset.operatorCatalogMoveDirection = "down"; + down.disabled = busy || index + 1 === catalog.draftRows.length; + const remove = document.createElement("button"); + remove.type = "button"; + remove.textContent = isTheme ? "삭제" : "종목삭제"; + remove.dataset.operatorCatalogRemoveRowId = row.rowId; + remove.disabled = busy; + line.append(order, name, detail, up, down, remove); + draftFragment.appendChild(line); + }); + operatorCatalogDraftRows.replaceChildren(draftFragment); + + operatorCatalogSave.disabled = busy || catalog.canSave !== true; + operatorCatalogDelete.disabled = busy || catalog.canDelete !== true; + operatorCatalogCancel.disabled = busy || !editing; + const messages = []; + if (catalog.statusMessage) messages.push(catalog.statusMessage); + if (catalog.resultsAreTruncated) messages.push("목록 일부만 표시 중입니다."); + if (catalog.stockResultsAreTruncated) messages.push("종목 검색 결과 일부만 표시 중입니다."); + if (catalog.writesQuarantined) { + messages.push("결과 불명확 상태로 현재 프로세스의 추가 DB 쓰기가 차단되었습니다."); + } + operatorCatalogStatus.textContent = messages.join(" "); + operatorCatalogStatus.classList.toggle( + "error", + catalog.writesQuarantined === true || catalog.statusKind === "error"); } function renderDialog(state) { @@ -191,7 +1707,24 @@ if (document.activeElement !== searchInput) searchInput.value = state.searchText || ""; renderStockResults(state); renderCuts(state); + renderTabs(state); + movingAverage5.checked = state.movingAverages ? state.movingAverages.ma5 : true; + movingAverage20.checked = state.movingAverages ? state.movingAverages.ma20 : true; + movingAverage5.disabled = isBusy; + movingAverage20.disabled = isBusy; + catalogExpandAll.disabled = isBusy; + renderFixedCatalog(state); + renderIndustry(state); + renderOverseas(state); + renderComparison(state); + renderTheme(state); + renderExpert(state); + renderTradingHalt(state); + renderManualFinancial(state); + renderManualLists(state); + renderOperatorCatalog(state); renderPlaylist(state); + renderNamedPlaylist(state); renderDialog(state); status.textContent = isBusy ? "종목을 검색하고 있습니다." : (state.statusMessage || ""); } @@ -207,6 +1740,385 @@ } }); + dbSaveButton.addEventListener("click", function () { + openNamedPlaylist("save"); + }); + dbLoadButton.addEventListener("click", function () { + openNamedPlaylist("load"); + }); + namedPlaylistClose.addEventListener("click", closeNamedPlaylist); + namedPlaylistCancelButton.addEventListener("click", closeNamedPlaylist); + namedPlaylistDefinitions.addEventListener("dblclick", function (event) { + const definition = event.target.closest("button[data-named-playlist-definition-id]"); + if (!definition || definition.disabled) return; + event.preventDefault(); + clearDelayedClick(namedPlaylistResultClickTimer); + namedPlaylistResultClickTimer = null; + const definitionId = definition.dataset.namedPlaylistDefinitionId; + if (namedPlaylistMode === "save" && + !window.confirm("선택한 DB 재생목록에 현재 목록을 저장하시겠습니까?")) { + return; + } + const command = namedPlaylistMode === "save" + ? "save-current-named-playlist-to" : "load-named-playlist-by-id"; + pendingNamedPlaylistCommand = { + command: command, + definitionId: definitionId, + minimumSequence: lastCommandSequence + 1 + }; + send(command, { definitionId: definitionId }); + }); + namedPlaylistDefinitions.addEventListener("click", function (event) { + const definition = event.target.closest("button[data-named-playlist-definition-id]"); + if (definition && !definition.disabled) { + clearDelayedClick(namedPlaylistResultClickTimer); + namedPlaylistResultClickTimer = window.setTimeout(function () { + namedPlaylistResultClickTimer = null; + send("select-named-playlist", { + definitionId: definition.dataset.namedPlaylistDefinitionId + }); + }, 250); + } + }); + namedPlaylistNewTitle.addEventListener("input", function () { + namedPlaylistCreateButton.disabled = uiBusy || !namedPlaylistState || + namedPlaylistState.canCreate !== true || + !isCanonicalNamedPlaylistTitle(namedPlaylistNewTitle.value); + }); + namedPlaylistNewTitle.addEventListener("keydown", function (event) { + if (event.key === "Enter" && !namedPlaylistCreateButton.disabled) { + event.preventDefault(); + namedPlaylistCreateButton.click(); + } + }); + namedPlaylistCreateButton.addEventListener("click", function () { + const title = namedPlaylistNewTitle.value; + if (isCanonicalNamedPlaylistTitle(title)) { + send("create-named-playlist", { title: title }); + } + }); + namedPlaylistDeleteButton.addEventListener("click", function () { + const title = namedPlaylistState && namedPlaylistState.selectedTitle + ? "'" + namedPlaylistState.selectedTitle + "'" : "선택한 재생목록"; + if (window.confirm(title + "을(를) DB에서 삭제하겠습니까?")) { + send("delete-selected-named-playlist", {}); + } + }); + namedPlaylistConfirmButton.addEventListener("click", function () { + if (namedPlaylistMode === "save") { + send("save-current-named-playlist", {}); + } else if (namedPlaylistMode === "load") { + send("load-selected-named-playlist", {}); + } + }); + + manualButtons.forEach(function (button) { + button.addEventListener("click", function () { + clearDelayedClick(manualResultClickTimer); + manualResultClickTimer = null; + manualDraft = null; + manualDraftKey = ""; + send("open-manual-financial", { screen: button.dataset.manualScreen }); + }); + }); + manualClose.addEventListener("click", function () { + clearDelayedClick(manualResultClickTimer); + manualResultClickTimer = null; + send("close-manual-financial", {}); + }); + manualListButtons.forEach(function (button) { + button.addEventListener("click", function () { + clearDelayedClick(manualViResultClickTimer); + manualViResultClickTimer = null; + send("open-manual-list", { screen: button.dataset.manualListScreen }); + }); + }); + manualListClose.addEventListener("click", function () { + clearDelayedClick(manualViResultClickTimer); + manualViResultClickTimer = null; + send("close-manual-list", {}); + }); + manualWorkspace.addEventListener("dblclick", function (event) { + const row = event.target.closest("button[data-manual-result-id]"); + if (!row || row.disabled) return; + event.preventDefault(); + clearDelayedClick(manualResultClickTimer); + manualResultClickTimer = null; + manualDraft = null; + manualDraftKey = ""; + send("activate-manual-financial-result", { + resultId: row.dataset.manualResultId + }); + }); + manualWorkspace.addEventListener("click", function (event) { + const row = event.target.closest("button[data-manual-result-id]"); + if (row) { + clearDelayedClick(manualResultClickTimer); + const resultId = row.dataset.manualResultId; + manualResultClickTimer = window.setTimeout(function () { + manualResultClickTimer = null; + manualDraft = null; + manualDraftKey = ""; + send("load-manual-financial", { resultId: resultId }); + }, 250); + return; + } + const find = event.target.closest("button[data-manual-find-direction]"); + if (find && !find.disabled) { + const query = manualWorkspace.querySelector("#manual-financial-find-query"); + const generation = Number(find.dataset.manualGeneration); + if (query && query.value.trim() && Number.isSafeInteger(generation)) { + send("find-manual-financial", { + query: query.value, + direction: find.dataset.manualFindDirection, + generation: generation + }); + } + return; + } + const sort = event.target.closest("button[data-manual-name-sort]"); + if (sort && !sort.disabled) { + const generation = Number(sort.dataset.manualGeneration); + if (Number.isSafeInteger(generation)) { + send("toggle-manual-financial-name-sort", { generation: generation }); + } + return; + } + if (event.target.closest("button[data-manual-list-search]")) { + const query = manualWorkspace.querySelector("#manual-financial-list-query"); + send("search-manual-financial", { query: query ? query.value : "" }); + return; + } + if (event.target.closest("button[data-manual-create]")) { + const screen = manualDraft ? manualDraft.screen : ""; + manualDraft = blankManualRecord(screen); + manualDraftKey = screen + "|new"; + send("begin-manual-financial-create", {}); + return; + } + if (event.target.closest("button[data-manual-stock-search]")) { + rememberManualDraft(); + const stockName = manualWorkspace.querySelector("#manual-financial-stock-name"); + send("search-manual-financial-stocks", { + stockName: stockName ? stockName.value.trim() : "" + }); + return; + } + const stock = event.target.closest("button[data-manual-stock-result-id]"); + if (stock) { + rememberManualDraft(); + send("select-manual-financial-stock", { + resultId: stock.dataset.manualStockResultId + }); + return; + } + if (event.target.closest("button[data-manual-save]")) { + const record = readManualRecord(); + if (record) send("save-manual-financial", { record: record }); + return; + } + if (event.target.closest("button[data-manual-delete]")) { + if (window.confirm("선택한 수동 재무 데이터를 삭제하시겠습니까?")) { + send("delete-manual-financial", {}); + } + return; + } + if (event.target.closest("button[data-manual-delete-all]")) { + if (window.confirm("현재 화면의 수동 재무 데이터를 모두 삭제하시겠습니까?")) { + send("delete-all-manual-financial", {}); + } + return; + } + const action = event.target.closest("button[data-manual-action-id]"); + if (action && !action.disabled) { + send("activate-manual-financial-action", { + actionId: action.dataset.manualActionId + }); + } + }); + manualWorkspace.addEventListener("keydown", function (event) { + if (event.key !== "Enter") return; + if (event.target.id === "manual-financial-list-query") { + event.preventDefault(); + send("search-manual-financial", { query: event.target.value }); + } else if (event.target.id === "manual-financial-find-query") { + event.preventDefault(); + const down = manualWorkspace.querySelector( + "button[data-manual-find-direction=\"down\"]"); + if (down) down.click(); + } else if (event.target.id === "manual-financial-stock-name") { + event.preventDefault(); + rememberManualDraft(); + send("search-manual-financial-stocks", { + stockName: event.target.value.trim() + }); + } + }); + + operatorCatalogClose.addEventListener("click", function () { + clearDelayedClick(operatorCatalogStockClickTimer); + operatorCatalogStockClickTimer = null; + send("close-operator-catalog", {}); + }); + operatorCatalogThemeTab.addEventListener("click", function () { + send("open-operator-catalog", { entity: "theme" }); + }); + operatorCatalogExpertTab.addEventListener("click", function () { + send("open-operator-catalog", { entity: "expert" }); + }); + function searchOperatorCatalog() { + send("search-operator-catalog", { + query: operatorCatalogQuery.value, + nxtSession: operatorCatalogSession.value === "afterMarket" + ? "afterMarket" : "preMarket" + }); + } + operatorCatalogSearch.addEventListener("click", searchOperatorCatalog); + operatorCatalogQuery.addEventListener("keydown", function (event) { + if (event.key === "Enter") { + event.preventDefault(); + searchOperatorCatalog(); + } + }); + operatorCatalogNew.addEventListener("click", function () { + if (!operatorCatalogState) return; + operatorCatalogDraftKey = ""; + operatorCatalogNameDraft = ""; + if (operatorCatalogState.entity === "theme") { + send("begin-create-theme-catalog", { + market: operatorCatalogNewMarket.value === "nxt" ? "nxt" : "krx" + }); + } else { + send("begin-create-expert-catalog", {}); + } + }); + operatorCatalogNewMarket.addEventListener("change", function () { + if (operatorCatalogState && operatorCatalogState.entity === "theme" && + operatorCatalogState.mode === "create") { + operatorCatalogDraftKey = ""; + operatorCatalogNameDraft = ""; + send("begin-create-theme-catalog", { + market: operatorCatalogNewMarket.value === "nxt" ? "nxt" : "krx" + }); + } + }); + operatorCatalogResults.addEventListener("click", function (event) { + const row = event.target.closest("button[data-operator-catalog-result-id]"); + if (!row || row.disabled) return; + operatorCatalogDraftKey = ""; + send("select-operator-catalog-result", { + resultId: row.dataset.operatorCatalogResultId + }); + }); + operatorCatalogName.addEventListener("input", function () { + operatorCatalogNameDraft = operatorCatalogName.value; + }); + function searchOperatorCatalogStocks() { + const query = operatorCatalogStockQuery.value.trim(); + if (query) send("search-operator-catalog-stocks", { query: query }); + } + operatorCatalogStockSearch.addEventListener("click", searchOperatorCatalogStocks); + operatorCatalogStockQuery.addEventListener("keydown", function (event) { + if (event.key === "Enter") { + event.preventDefault(); + searchOperatorCatalogStocks(); + } + }); + operatorCatalogStockResults.addEventListener("dblclick", function (event) { + const row = event.target.closest("button[data-operator-catalog-stock-result-id]"); + if (!row || row.disabled) return; + event.preventDefault(); + clearDelayedClick(operatorCatalogStockClickTimer); + operatorCatalogStockClickTimer = null; + const amount = readOperatorCatalogBuyAmount(); + if (!amount.valid) return; + send("activate-operator-catalog-stock", { + resultId: row.dataset.operatorCatalogStockResultId, + buyAmount: amount.value + }); + }); + operatorCatalogStockResults.addEventListener("click", function (event) { + const row = event.target.closest("button[data-operator-catalog-stock-result-id]"); + if (!row || row.disabled) return; + clearDelayedClick(operatorCatalogStockClickTimer); + const resultId = row.dataset.operatorCatalogStockResultId; + operatorCatalogStockClickTimer = window.setTimeout(function () { + operatorCatalogStockClickTimer = null; + send("select-operator-catalog-stock", { resultId: resultId }); + }, 250); + }); + function readOperatorCatalogBuyAmount() { + if (!operatorCatalogState) return { valid: false, value: null }; + if (operatorCatalogState.entity === "expert") { + const amount = Number(operatorCatalogAmount.value); + if (!Number.isSafeInteger(amount) || amount <= 0) { + operatorCatalogAmount.setCustomValidity("매수가는 1 이상의 정수여야 합니다."); + operatorCatalogAmount.reportValidity(); + return { valid: false, value: null }; + } + operatorCatalogAmount.setCustomValidity(""); + return { valid: true, value: amount }; + } + return { valid: true, value: null }; + } + operatorCatalogAddStock.addEventListener("click", function () { + const amount = readOperatorCatalogBuyAmount(); + if (amount.valid) { + send("add-operator-catalog-stock", { buyAmount: amount.value }); + } + }); + operatorCatalogDraftRows.addEventListener("click", function (event) { + const move = event.target.closest("button[data-operator-catalog-move-row-id]"); + if (move && !move.disabled) { + send("move-operator-catalog-draft-row", { + rowId: move.dataset.operatorCatalogMoveRowId, + direction: move.dataset.operatorCatalogMoveDirection + }); + return; + } + const remove = event.target.closest("button[data-operator-catalog-remove-row-id]"); + if (remove && !remove.disabled) { + send("remove-operator-catalog-draft-row", { + rowId: remove.dataset.operatorCatalogRemoveRowId + }); + } + }); + operatorCatalogDraftRows.addEventListener("change", function (event) { + const input = event.target.closest("input[data-operator-catalog-buy-amount-row-id]"); + if (!input || input.disabled) return; + const amount = Number(input.value); + if (!Number.isSafeInteger(amount) || amount <= 0) { + input.setCustomValidity("매수가는 1 이상의 정수여야 합니다."); + input.reportValidity(); + return; + } + input.setCustomValidity(""); + send("set-operator-catalog-draft-buy-amount", { + rowId: input.dataset.operatorCatalogBuyAmountRowId, + buyAmount: amount + }); + }); + operatorCatalogSave.addEventListener("click", function () { + const name = operatorCatalogName.value.trim(); + if (!name) { + operatorCatalogName.setCustomValidity("이름을 입력하세요."); + operatorCatalogName.reportValidity(); + return; + } + operatorCatalogName.setCustomValidity(""); + operatorCatalogNameDraft = name; + send("save-operator-catalog", { name: name }); + }); + operatorCatalogDelete.addEventListener("click", function () { + if (window.confirm("선택한 ThemeA/EList 항목을 DB에서 삭제하시겠습니까?")) { + send("delete-operator-catalog", {}); + } + }); + operatorCatalogCancel.addEventListener("click", function () { + operatorCatalogDraftKey = ""; + searchOperatorCatalog(); + }); + playlistDropZone.addEventListener("dragover", function (event) { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "move"; @@ -215,6 +2127,412 @@ event.preventDefault(); send("drop-selected-cuts", {}); }); + playlistMoveUp.addEventListener("click", function () { + send("move-selected-playlist-rows", { direction: "up" }); + }); + playlistMoveDown.addEventListener("click", function () { + send("move-selected-playlist-rows", { direction: "down" }); + }); + playlistDeleteSelected.addEventListener("click", function () { + send("delete-selected-playlist-rows", {}); + }); + playlistDeleteAll.addEventListener("click", function () { + if (window.confirm("송출 리스트를 모두 삭제하겠습니까?")) { + send("delete-all-playlist-rows", {}); + } + }); + playlistEnableAll.addEventListener("change", function () { + send("set-all-playlist-enabled", { enabled: playlistEnableAll.checked }); + }); + marketTabs.addEventListener("click", function (event) { + const button = event.target.closest("button[data-tab-id]"); + if (button) { + clearDelayedClick(comparisonTargetClickTimer); + comparisonTargetClickTimer = null; + send("select-tab", { tabId: button.dataset.tabId }); + } + }); + marketTabs.addEventListener("dragstart", function (event) { + const button = event.target.closest("button[data-tab-id]"); + if (!button || !event.dataTransfer) return; + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/x-mbn-tab", button.dataset.tabId); + }); + marketTabs.addEventListener("dragover", function (event) { + if (event.target.closest("button[data-tab-id]")) event.preventDefault(); + }); + marketTabs.addEventListener("drop", function (event) { + const target = event.target.closest("button[data-tab-id]"); + if (!target || !event.dataTransfer) return; + event.preventDefault(); + const source = event.dataTransfer.getData("text/x-mbn-tab"); + if (source && source !== target.dataset.tabId) { + send("swap-tabs", { source: source, target: target.dataset.tabId }); + } + }); + function sendMovingAverages() { + send("set-moving-averages", { + ma5: movingAverage5.checked, + ma20: movingAverage20.checked + }); + } + movingAverage5.addEventListener("change", sendMovingAverages); + movingAverage20.addEventListener("change", sendMovingAverages); + catalogExpandAll.addEventListener("change", function () { + categoryTree.querySelectorAll("details").forEach(function (details) { + details.open = catalogExpandAll.checked; + }); + }); + categoryTree.addEventListener("dblclick", function (event) { + const themeResult = event.target.closest("button[data-theme-result-id]"); + if (themeResult && !themeResult.disabled) { + event.preventDefault(); + clearDelayedClick(themeResultClickTimer); + themeResultClickTimer = null; + send("activate-theme-result", { + resultId: themeResult.dataset.themeResultId + }); + return; + } + const comparisonChoice = event.target.closest("button[data-comparison-choice]"); + if (comparisonChoice && !comparisonChoice.disabled) { + event.preventDefault(); + clearDelayedClick(comparisonTargetClickTimer); + comparisonTargetClickTimer = null; + send(comparisonChoice.dataset.comparisonChoice === "world" + ? "choose-comparison-world" : "choose-comparison-domestic", { + selectionId: comparisonChoice.dataset.comparisonSelectionId + }); + return; + } + const comparisonFixed = event.target.closest("button[data-comparison-target-id]"); + if (comparisonFixed && !comparisonFixed.disabled) { + event.preventDefault(); + clearDelayedClick(comparisonTargetClickTimer); + comparisonTargetClickTimer = null; + send("choose-comparison-fixed", { + targetId: comparisonFixed.dataset.comparisonTargetId + }); + return; + } + const comparisonSection = event.target.closest("summary[data-comparison-section-index]"); + if (comparisonSection) { + event.preventDefault(); + send("activate-comparison-section", { + sectionIndex: Number(comparisonSection.dataset.comparisonSectionIndex) + }); + return; + } + const industrySection = event.target.closest("summary[data-industry-section-index]"); + if (industrySection) { + event.preventDefault(); + send("activate-industry-section", { + sectionIndex: Number(industrySection.dataset.industrySectionIndex) + }); + return; + } + const comparisonAction = event.target.closest("button[data-comparison-action-id]"); + if (comparisonAction && !comparisonAction.disabled) { + send("activate-comparison-action", { + actionId: comparisonAction.dataset.comparisonActionId + }); + return; + } + const industryRow = event.target.closest("button[data-industry-index]"); + if (industryRow) { + send("double-click-industry", { index: Number(industryRow.dataset.industryIndex) }); + return; + } + const industryAction = event.target.closest("button[data-industry-action-id]"); + if (industryAction) { + send("activate-industry-action", { + actionId: industryAction.dataset.industryActionId + }); + return; + } + const action = event.target.closest("button[data-action-id]"); + if (action) { + send("activate-fixed-action", { actionId: action.dataset.actionId }); + return; + } + const section = event.target.closest("summary[data-section-index]"); + if (section && section.getAttribute("aria-disabled") !== "true") { + event.preventDefault(); + send("activate-fixed-section", { + sectionIndex: Number(section.dataset.sectionIndex) + }); + } + }); + categoryTree.addEventListener("click", function (event) { + const uc4Catalog = event.target.closest("button[data-uc4-theme-catalog]"); + if (uc4Catalog && !uc4Catalog.disabled) { + const action = uc4Catalog.dataset.uc4ThemeCatalog; + if (action === "create") { + send("begin-uc4-theme-catalog", {}); + } else if (action === "edit") { + send("edit-uc4-selected-theme", {}); + } else if (action === "delete" && + window.confirm("선택한 테마를 삭제하겠습니까?")) { + send("delete-uc4-selected-theme", {}); + } + return; + } + const uc6Catalog = event.target.closest("button[data-uc6-expert-catalog]"); + if (uc6Catalog && !uc6Catalog.disabled) { + const action = uc6Catalog.dataset.uc6ExpertCatalog; + if (action === "create") { + send("begin-uc6-expert-catalog", {}); + } else if (action === "edit") { + send("edit-uc6-selected-expert", {}); + } else if (action === "delete" && + window.confirm("선택한 전문가를 삭제하겠습니까?")) { + send("delete-uc6-selected-expert", {}); + } + return; + } + const catalogOpen = event.target.closest("button[data-operator-catalog-open]"); + if (catalogOpen && !catalogOpen.disabled) { + send("open-operator-catalog", { + entity: catalogOpen.dataset.operatorCatalogOpen + }); + return; + } + // UC4/UC6/UC7 wire their scene action controls to WinForms Click. + // Keep these actions out of the dblclick delegate above so one operator + // click produces exactly one closed native intent. + const themeAction = event.target.closest("button[data-theme-action-id]"); + if (themeAction && !themeAction.disabled) { + send("activate-theme-action", { actionId: themeAction.dataset.themeActionId }); + return; + } + const expertAction = event.target.closest("button[data-expert-action-id]"); + if (expertAction && !expertAction.disabled) { + send("activate-expert-action", { actionId: expertAction.dataset.expertActionId }); + return; + } + const haltAction = event.target.closest("button[data-halt-action-id]"); + if (haltAction && !haltAction.disabled) { + send("activate-trading-halt-action", { actionId: haltAction.dataset.haltActionId }); + return; + } + const overseasAction = event.target.closest("button[data-overseas-action-id]"); + if (overseasAction && !overseasAction.disabled) { + send("activate-overseas-action", { + actionId: overseasAction.dataset.overseasActionId + }); + return; + } + const overseasTarget = event.target.closest("button[data-overseas-target-id]"); + if (overseasTarget) { + send("select-overseas-fixed-index", { + targetId: overseasTarget.dataset.overseasTargetId + }); + return; + } + const overseasResult = event.target.closest("button[data-overseas-selection-id]"); + if (overseasResult) { + send(overseasResult.dataset.overseasResultKind === "stock" + ? "select-overseas-stock" : "select-overseas-industry", { + selectionId: overseasResult.dataset.overseasSelectionId + }); + return; + } + const overseasSearch = event.target.closest("button[data-overseas-search]"); + if (overseasSearch) { + const kind = overseasSearch.dataset.overseasSearch; + const input = categoryTree.querySelector("#overseas-" + kind + "-search"); + send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", { + query: input ? input.value : "" + }); + return; + } + const comparisonChoice = event.target.closest("button[data-comparison-choice]"); + if (comparisonChoice) { + clearDelayedClick(comparisonTargetClickTimer); + const kind = comparisonChoice.dataset.comparisonChoice; + const selectionId = comparisonChoice.dataset.comparisonSelectionId; + comparisonTargetClickTimer = window.setTimeout(function () { + comparisonTargetClickTimer = null; + send(kind === "world" + ? "select-comparison-world" : "select-comparison-domestic", { + selectionId: selectionId + }); + }, 250); + return; + } + const comparisonFixed = event.target.closest("button[data-comparison-target-id]"); + if (comparisonFixed) { + clearDelayedClick(comparisonTargetClickTimer); + const targetId = comparisonFixed.dataset.comparisonTargetId; + comparisonTargetClickTimer = window.setTimeout(function () { + comparisonTargetClickTimer = null; + send("select-comparison-fixed", { targetId: targetId }); + }, 250); + return; + } + const comparisonPair = event.target.closest("button[data-comparison-pair-id]"); + if (comparisonPair) { + send("select-comparison-pair", { rowId: comparisonPair.dataset.comparisonPairId }); + return; + } + const comparisonSearch = event.target.closest("button[data-comparison-search]"); + if (comparisonSearch) { + const kind = comparisonSearch.dataset.comparisonSearch; + const input = categoryTree.querySelector("#comparison-" + kind + "-search"); + const searchIntent = kind === "world" + ? "search-comparison-world" : "search-comparison-domestic"; + send(searchIntent, { query: input ? input.value : "" }); + return; + } + if (event.target.closest("button[data-comparison-swap]")) { + send("swap-comparison-targets", {}); + return; + } + if (event.target.closest("button[data-comparison-clear]")) { + send("clear-comparison-targets", {}); + return; + } + if (event.target.closest("button[data-comparison-add]")) { + send("add-comparison-pair", {}); + return; + } + const comparisonMove = event.target.closest("button[data-comparison-move]"); + if (comparisonMove) { + send("move-comparison-pair", { direction: comparisonMove.dataset.comparisonMove }); + return; + } + const comparisonDelete = event.target.closest("button[data-comparison-delete]"); + if (comparisonDelete) { + const all = comparisonDelete.dataset.comparisonDelete === "all"; + if (!all || window.confirm("저장된 종목 비교쌍을 모두 삭제하겠습니까?")) { + send(all ? "delete-all-comparison-pairs" : + "delete-selected-comparison-pair", {}); + } + return; + } + const expertResult = event.target.closest("button[data-expert-selection-id]"); + if (expertResult) { + send("select-expert", { selectionId: expertResult.dataset.expertSelectionId }); + return; + } + if (event.target.closest("button[data-expert-search]")) { + const input = categoryTree.querySelector("#expert-search"); + send("search-experts", { query: input ? input.value : "" }); + return; + } + const haltResult = event.target.closest("button[data-halt-selection-id]"); + if (haltResult) { + send("select-trading-halt", { selectionId: haltResult.dataset.haltSelectionId }); + return; + } + if (event.target.closest("button[data-halt-search]")) { + const input = categoryTree.querySelector("#halt-search"); + send("search-trading-halts", { query: input ? input.value : "" }); + return; + } + if (event.target.closest("button[data-halt-sort]")) { + send("toggle-trading-halt-sort", {}); + return; + } + const themeResult = event.target.closest("button[data-theme-result-id]"); + if (themeResult) { + clearDelayedClick(themeResultClickTimer); + const resultId = themeResult.dataset.themeResultId; + themeResultClickTimer = window.setTimeout(function () { + themeResultClickTimer = null; + send("select-theme", { resultId: resultId }); + }, 250); + return; + } + if (event.target.closest("button[data-theme-search]")) { + const input = categoryTree.querySelector("#theme-search"); + const session = categoryTree.querySelector("#theme-nxt-session"); + send("search-themes", { + query: input ? input.value : "", + nxtSession: session ? session.value : "preMarket" + }); + return; + } + const industryRow = event.target.closest("button[data-industry-index]"); + if (industryRow) { + send("select-industry", { index: Number(industryRow.dataset.industryIndex) }); + return; + } + if (event.target.closest("button[data-industry-swap]")) { + send("swap-industries", {}); + } + }); + categoryTree.addEventListener("change", function (event) { + if (event.target.matches("select[data-theme-sort]")) { + send("set-theme-sort", { sortId: event.target.value }); + } + }); + categoryTree.addEventListener("keydown", function (event) { + if (event.key === "Enter" && + (event.target.id === "overseas-industry-search" || + event.target.id === "overseas-stock-search")) { + event.preventDefault(); + const kind = event.target.id === "overseas-stock-search" ? "stock" : "industry"; + send(kind === "stock" ? "search-overseas-stocks" : "search-overseas-industries", { + query: event.target.value + }); + } else if (event.key === "Enter" && + (event.target.id === "comparison-domestic-search" || + event.target.id === "comparison-world-search")) { + event.preventDefault(); + const kind = event.target.id === "comparison-world-search" ? "world" : "domestic"; + send(kind === "world" ? "search-comparison-world" : + "search-comparison-domestic", { query: event.target.value }); + } else if (event.key === "Enter" && event.target.id === "theme-search") { + event.preventDefault(); + const session = categoryTree.querySelector("#theme-nxt-session"); + send("search-themes", { + query: event.target.value, + nxtSession: session ? session.value : "preMarket" + }); + } else if (event.key === "Enter" && event.target.id === "expert-search") { + event.preventDefault(); + send("search-experts", { query: event.target.value }); + } else if (event.key === "Enter" && event.target.id === "halt-search") { + event.preventDefault(); + send("search-trading-halts", { query: event.target.value }); + } + }); + document.addEventListener("keydown", function (event) { + if (event.key === "Delete" && !event.repeat && dialog.hidden && manualModal.hidden && + manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden) { + event.preventDefault(); + send("delete-selected-playlist-rows", {}); + } else if (event.key === " " && !event.repeat && dialog.hidden && manualModal.hidden && + manualListModal.hidden && namedPlaylistModal.hidden && operatorCatalogModal.hidden && + event.target.closest(".playlist-data-row")) { + event.preventDefault(); + send("toggle-active-playlist-enabled", {}); + } else if ((event.key === "Home" || event.key === "End") && + !event.repeat && dialog.hidden && manualModal.hidden && manualListModal.hidden && + namedPlaylistModal.hidden && operatorCatalogModal.hidden) { + event.preventDefault(); + send("select-playlist-boundary", { + boundary: event.key === "Home" ? "first" : "last" + }); + } else if (event.key === "Escape" && !event.repeat && !namedPlaylistModal.hidden) { + event.preventDefault(); + closeNamedPlaylist(); + } else if (event.key === "Escape" && !event.repeat && !manualModal.hidden) { + event.preventDefault(); + clearDelayedClick(manualResultClickTimer); + manualResultClickTimer = null; + send("close-manual-financial", {}); + } else if (event.key === "Escape" && !event.repeat && !manualListModal.hidden) { + event.preventDefault(); + send("close-manual-list", {}); + } else if (event.key === "Escape" && !event.repeat && !operatorCatalogModal.hidden) { + event.preventDefault(); + clearDelayedClick(operatorCatalogStockClickTimer); + operatorCatalogStockClickTimer = null; + send("close-operator-catalog", {}); + } + }); dialogOk.addEventListener("click", function () { send("dismiss-dialog", {}); }); diff --git a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html index 268704f..34cbaf5 100644 --- a/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html +++ b/src/MBN_STOCK_WEBVIEW.LegacyParityApp/Web/index.html @@ -11,7 +11,8 @@
매일경제TVVRi
-
미연결
+
미연결
@@ -23,10 +24,16 @@
- - - - + + + + +
+
@@ -34,74 +41,195 @@
-
- - - - - + + + + + - - - - - - - + + + + + + + + +
송출
종류
종목
컷파일
세부사항
Page
-
+
- - - + + + +
-
원본 호환 UI 기준선 · 송출 연결 안 함
+
송출 상태를 확인하고 있습니다.
+ + + + + + + + ", Markup); - Assert.Contains( - "class=\"active\" type=\"button\" disabled>해외", - Markup, - StringComparison.Ordinal); - Assert.Contains( - "type=\"button\" disabled>정지", - Markup, - StringComparison.Ordinal); + Assert.Matches("class=\"active\"[^>]*data-tab-id=\"overseas\">해외", Markup); + Assert.Matches("data-tab-id=\"tradingHalt\">정지", Markup); } [Fact] @@ -66,7 +77,9 @@ public sealed class LegacyWebContractTests Assert.Contains("timestampMilliseconds", App, StringComparison.Ordinal); Assert.Contains("event.ctrlKey", App, StringComparison.Ordinal); Assert.Contains("event.shiftKey", App, StringComparison.Ordinal); - Assert.DoesNotContain("dblclick", App, StringComparison.Ordinal); + var cutFactory = App[ + App.IndexOf("function createCutElement", StringComparison.Ordinal)..App.IndexOf("function renderCuts", StringComparison.Ordinal)]; + Assert.DoesNotContain("dblclick", cutFactory, StringComparison.Ordinal); } [Fact] @@ -86,6 +99,161 @@ public sealed class LegacyWebContractTests Assert.DoesNotContain("playlistRows.replaceChildren", App, StringComparison.Ordinal); } + [Fact] + public void PlaylistRowsSendSelectionAndMutationIntentsOnly() + { + foreach (var intent in new[] + { + "select-playlist-row", + "set-playlist-enabled", + "move-selected-playlist-rows", + "delete-selected-playlist-rows", + "delete-all-playlist-rows" + }) + { + Assert.Contains($"send(\"{intent}\"", App, StringComparison.Ordinal); + } + + Assert.Contains("control: event.ctrlKey", App, StringComparison.Ordinal); + Assert.Contains("shift: event.shiftKey", App, StringComparison.Ordinal); + Assert.Contains("event.key === \"Delete\"", App, StringComparison.Ordinal); + Assert.Contains("row.isSelected", App, StringComparison.Ordinal); + Assert.Contains("enabled.disabled = state.isBusy === true", App, StringComparison.Ordinal); + } + + [Fact] + public void PlaylistReorderUsesKeyedDomNodesAndPreservesFocus() + { + Assert.Contains("const existingRows = new Map()", App, StringComparison.Ordinal); + Assert.Contains("existingRows.get(row.rowId)", App, StringComparison.Ordinal); + Assert.Contains("playlistRows.insertBefore(element, elementAtPosition)", App, StringComparison.Ordinal); + Assert.Contains("const retainedRowIds = new Set", App, StringComparison.Ordinal); + Assert.DoesNotContain("playlistRows.replaceChild", App, StringComparison.Ordinal); + } + + [Fact] + public void FixedCatalogUsesOpaqueCSharpActionAndSectionIntents() + { + Assert.Contains("state.fixedCatalog", App, StringComparison.Ordinal); + Assert.Contains("dataset.actionId", App, StringComparison.Ordinal); + Assert.Contains("dataset.sectionIndex", App, StringComparison.Ordinal); + Assert.Contains("send(\"activate-fixed-action\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"activate-fixed-section\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"activate-industry-section\"", App, + StringComparison.Ordinal); + Assert.Contains("send(\"activate-comparison-section\"", App, + StringComparison.Ordinal); + Assert.Contains("dataset.industrySectionIndex", App, StringComparison.Ordinal); + Assert.Contains("dataset.comparisonSectionIndex", App, StringComparison.Ordinal); + Assert.Contains("send(\"set-moving-averages\"", App, StringComparison.Ordinal); + } + + [Fact] + public void IndustryWorkspaceUsesIndexesAndOpaqueActionIds() + { + Assert.Contains("state.industry", App, StringComparison.Ordinal); + Assert.Contains("dataset.industryIndex", App, StringComparison.Ordinal); + Assert.Contains("dataset.industryActionId", App, StringComparison.Ordinal); + Assert.Contains("send(\"select-industry\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"double-click-industry\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"swap-industries\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"activate-industry-action\"", App, StringComparison.Ordinal); + } + + [Fact] + public void ComparisonWorkspaceUsesOnlyOpaqueTargetPairAndActionIds() + { + foreach (var value in new[] + { + "search-comparison-domestic", + "search-comparison-world", + "select-comparison-domestic", + "select-comparison-world", + "select-comparison-fixed", + "choose-comparison-domestic", + "choose-comparison-world", + "choose-comparison-fixed", + "swap-comparison-targets", + "clear-comparison-targets", + "add-comparison-pair", + "select-comparison-pair", + "move-comparison-pair", + "delete-selected-comparison-pair", + "delete-all-comparison-pairs", + "activate-comparison-action" + }) + { + Assert.Contains($"\"{value}\"", App, StringComparison.Ordinal); + } + + Assert.Contains("dataset.comparisonSelectionId", App, StringComparison.Ordinal); + Assert.Contains("dataset.comparisonPairId", App, StringComparison.Ordinal); + Assert.Contains("dataset.comparisonActionId", App, StringComparison.Ordinal); + Assert.Contains("comparisonTargetClickTimer", App, StringComparison.Ordinal); + Assert.DoesNotContain("stockCode", App, StringComparison.Ordinal); + } + + [Fact] + public void ThemeWorkspaceUsesOpaqueSelectionSortAndActionIds() + { + Assert.Contains("state.theme", App, StringComparison.Ordinal); + Assert.Contains("dataset.themeResultId", App, StringComparison.Ordinal); + Assert.Contains("dataset.themeActionId", App, StringComparison.Ordinal); + Assert.Contains("send(\"search-themes\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"select-theme\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"set-theme-sort\"", App, StringComparison.Ordinal); + Assert.Contains("send(\"activate-theme-action\"", App, StringComparison.Ordinal); + } + + [Fact] + public void ExpertAndTradingHaltWorkspacesUseOpaqueIntents() + { + foreach (var intent in new[] + { + "search-experts", + "select-expert", + "activate-expert-action", + "search-trading-halts", + "select-trading-halt", + "toggle-trading-halt-sort", + "activate-trading-halt-action" + }) + { + Assert.Contains($"send(\"{intent}\"", App, StringComparison.Ordinal); + } + } + + [Fact] + public void DbSaveLoadUseClosedNamedPlaylistIntentsWhilePlayoutRemainsDisabled() + { + foreach (var intent in new[] + { + "refresh-named-playlists", + "select-named-playlist", + "load-selected-named-playlist", + "create-named-playlist", + "save-current-named-playlist", + "delete-selected-named-playlist" + }) + { + Assert.Contains($"\"{intent}\"", App, StringComparison.Ordinal); + } + + Assert.Contains("state.namedPlaylist", App, StringComparison.Ordinal); + Assert.Contains("dataset.namedPlaylistDefinitionId", App, StringComparison.Ordinal); + Assert.Contains("definitionId: definition.dataset.namedPlaylistDefinitionId", App, + StringComparison.Ordinal); + Assert.Contains("dbLoadButton.disabled = busy || !named", App, StringComparison.Ordinal); + Assert.Contains("named.isWriteQuarantined", App, StringComparison.Ordinal); + Assert.DoesNotContain("programCode", App, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("listText", App, StringComparison.OrdinalIgnoreCase); + + foreach (var label in new[] { "TAKE IN", "NEXT", "TAKE OUT" }) + { + Assert.Matches($"]*disabled[^>]*>[^<]*{Regex.Escape(label)}", Markup); + } + } + [Fact] public void SeparatorRowsPreserveOriginalDragGesture() { diff --git a/tests/MBN_STOCK_WEBVIEW.Playout.Tests/LegacyParityStartupCompositionResolverTests.cs b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/LegacyParityStartupCompositionResolverTests.cs new file mode 100644 index 0000000..ab168cc --- /dev/null +++ b/tests/MBN_STOCK_WEBVIEW.Playout.Tests/LegacyParityStartupCompositionResolverTests.cs @@ -0,0 +1,112 @@ +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Playout.Configuration; + +namespace MBN_STOCK_WEBVIEW.Playout.Tests; + +public sealed class LegacyParityStartupCompositionResolverTests +{ + [Fact] + public void AbsentLocalConfiguration_UsesValidatedLegacyDefaultAtConfiguredFade() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + CreateBackground(scenes, PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName); + var options = new PlayoutOptions + { + SceneDirectory = scenes.Path, + LegacySceneFadeDuration = 11 + }; + + var result = LegacyParityStartupCompositionResolver.Resolve( + options, + explicitLocalConfigurationExists: false); + + Assert.False(result.HasWarning); + Assert.Equal(11, result.Composition.FadeDuration); + Assert.Equal(LegacySceneBackgroundKind.Video, result.Composition.BackgroundKind); + Assert.Equal("기본.vrv", result.Composition.BackgroundAssetPath); + Assert.Equal( + PlayoutAssetRoot.OperatorBackgroundDirectory, + result.Composition.BackgroundAssetRoot); + } + + [Fact] + public void AbsentLocalConfigurationAndMissingDefault_RemainsNoneWithBoundedWarning() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + var options = new PlayoutOptions + { + SceneDirectory = scenes.Path, + LegacySceneFadeDuration = 9 + }; + + var result = LegacyParityStartupCompositionResolver.Resolve( + options, + explicitLocalConfigurationExists: false); + + Assert.True(result.HasWarning); + Assert.Equal( + LegacyParityStartupCompositionResolver.MissingLegacyDefaultWarning, + result.WarningMessage); + Assert.DoesNotContain(scenes.Path, result.WarningMessage, StringComparison.Ordinal); + Assert.InRange(result.WarningMessage.Length, 1, 128); + Assert.Equal(9, result.Composition.FadeDuration); + Assert.Equal(LegacySceneBackgroundKind.None, result.Composition.BackgroundKind); + Assert.Null(result.Composition.BackgroundAssetPath); + } + + [Fact] + public void ExplicitLocalNone_IsHonoredEvenWhenLegacyDefaultExists() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + CreateBackground(scenes, PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName); + var options = new PlayoutOptions + { + SceneDirectory = scenes.Path, + LegacySceneBackgroundKind = LegacySceneBackgroundKind.None + }; + + var result = LegacyParityStartupCompositionResolver.Resolve( + options, + explicitLocalConfigurationExists: true); + + Assert.False(result.HasWarning); + Assert.Equal(LegacySceneBackgroundKind.None, result.Composition.BackgroundKind); + Assert.Null(result.Composition.BackgroundAssetPath); + } + + [Fact] + public void ExplicitLocalAsset_IsHonoredWithoutReplacingItWithLegacyDefault() + { + using var scenes = TemporarySceneDirectory.Create("5001.t2s"); + CreateBackground( + scenes, + PlayoutSceneCompositionFactory.LegacyDefaultBackgroundFileName, + "custom.vrv"); + var options = new PlayoutOptions + { + SceneDirectory = scenes.Path, + LegacySceneBackgroundKind = LegacySceneBackgroundKind.Video, + LegacySceneBackgroundAssetPath = "custom.vrv" + }; + + var result = LegacyParityStartupCompositionResolver.Resolve( + options, + explicitLocalConfigurationExists: true); + + Assert.False(result.HasWarning); + Assert.Equal(LegacySceneBackgroundKind.Video, result.Composition.BackgroundKind); + Assert.Equal("custom.vrv", result.Composition.BackgroundAssetPath); + } + + private static void CreateBackground( + TemporarySceneDirectory scenes, + params string[] fileNames) + { + var background = Path.Combine(Directory.GetParent(scenes.Path)!.FullName, "배경"); + Directory.CreateDirectory(background); + foreach (var fileName in fileNames) + { + File.WriteAllText(Path.Combine(background, fileName), "trusted test background"); + } + } +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/DevelopmentDatabaseSmokeScenario.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/DevelopmentDatabaseSmokeScenario.cs new file mode 100644 index 0000000..171cc2f --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/DevelopmentDatabaseSmokeScenario.cs @@ -0,0 +1,169 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +internal enum DevelopmentDatabaseSmokeStage +{ + None, + Preflight, + Create, + CreateReadback, + Save, + SaveReadback, + Cleanup, + VerifyAbsent +} + +internal sealed record DevelopmentDatabaseSmokeResult( + string ScenarioName, + bool Passed, + DevelopmentDatabaseSmokeStage FailureStage, + string FailureClass, + bool OutcomeUnknown, + bool CleanupAttempted, + bool CleanupVerified, + bool ManualCleanupMayBeRequired); + +/// +/// Runs one collision-isolated development write scenario. Every delegate is +/// invoked at most once. A mutation with an unknown outcome quarantines the +/// scenario immediately: no cleanup mutation or later scenario may be issued. +/// +internal sealed class DevelopmentDatabaseSmokeScenario +{ + private readonly Func _preflight; + private readonly Func _create; + private readonly Func _verifyCreate; + private readonly Func? _save; + private readonly Func? _verifySave; + private readonly Func _cleanup; + private readonly Func _verifyAbsent; + private readonly Func _isOutcomeUnknown; + + internal DevelopmentDatabaseSmokeScenario( + string name, + Func preflight, + Func create, + Func verifyCreate, + Func? save, + Func? verifySave, + Func cleanup, + Func verifyAbsent, + Func isOutcomeUnknown) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + if ((save is null) != (verifySave is null)) + { + throw new ArgumentException( + "Save and save-readback delegates must either both be present or both be absent.", + nameof(save)); + } + + Name = name; + _preflight = preflight ?? throw new ArgumentNullException(nameof(preflight)); + _create = create ?? throw new ArgumentNullException(nameof(create)); + _verifyCreate = verifyCreate ?? throw new ArgumentNullException(nameof(verifyCreate)); + _save = save; + _verifySave = verifySave; + _cleanup = cleanup ?? throw new ArgumentNullException(nameof(cleanup)); + _verifyAbsent = verifyAbsent ?? throw new ArgumentNullException(nameof(verifyAbsent)); + _isOutcomeUnknown = isOutcomeUnknown ?? + throw new ArgumentNullException(nameof(isOutcomeUnknown)); + } + + internal string Name { get; } + + internal async Task RunAsync( + CancellationToken cancellationToken = default) + { + var currentStage = DevelopmentDatabaseSmokeStage.Preflight; + Exception? primaryFailure = null; + var failureStage = DevelopmentDatabaseSmokeStage.None; + var outcomeUnknown = false; + var failureClass = "none"; + var createKnownCommitted = false; + var cleanupAttempted = false; + var cleanupVerified = false; + + try + { + try + { + await _preflight(cancellationToken).ConfigureAwait(false); + + currentStage = DevelopmentDatabaseSmokeStage.Create; + await _create(cancellationToken).ConfigureAwait(false); + createKnownCommitted = true; + + currentStage = DevelopmentDatabaseSmokeStage.CreateReadback; + await _verifyCreate(cancellationToken).ConfigureAwait(false); + + if (_save is not null) + { + currentStage = DevelopmentDatabaseSmokeStage.Save; + await _save(cancellationToken).ConfigureAwait(false); + + currentStage = DevelopmentDatabaseSmokeStage.SaveReadback; + await _verifySave!(cancellationToken).ConfigureAwait(false); + } + } + catch (Exception exception) + { + primaryFailure = exception; + failureStage = currentStage; + failureClass = RedactedDatabaseFailureClassifier.Classify(exception); + outcomeUnknown = _isOutcomeUnknown(exception); + } + } + finally + { + // A known successful create is cleaned up even when a later read-only + // verification fails. An unknown mutation outcome is quarantined and + // never followed by another mutation. + if (createKnownCommitted && !outcomeUnknown) + { + cleanupAttempted = true; + try + { + currentStage = DevelopmentDatabaseSmokeStage.Cleanup; + await _cleanup(CancellationToken.None).ConfigureAwait(false); + + currentStage = DevelopmentDatabaseSmokeStage.VerifyAbsent; + await _verifyAbsent(CancellationToken.None).ConfigureAwait(false); + cleanupVerified = true; + } + catch (Exception exception) + { + primaryFailure = exception; + failureStage = currentStage; + failureClass = RedactedDatabaseFailureClassifier.Classify(exception); + outcomeUnknown = _isOutcomeUnknown(exception); + } + } + } + + if (primaryFailure is null && cleanupVerified) + { + return new DevelopmentDatabaseSmokeResult( + Name, + Passed: true, + DevelopmentDatabaseSmokeStage.None, + "none", + OutcomeUnknown: false, + CleanupAttempted: true, + CleanupVerified: true, + ManualCleanupMayBeRequired: false); + } + + return new DevelopmentDatabaseSmokeResult( + Name, + Passed: false, + failureStage, + failureClass, + outcomeUnknown, + cleanupAttempted, + cleanupVerified, + ManualCleanupMayBeRequired: outcomeUnknown && + (createKnownCommitted || failureStage == DevelopmentDatabaseSmokeStage.Create)); + } +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/DevelopmentDatabaseWriteSmokeApp.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/DevelopmentDatabaseWriteSmokeApp.cs new file mode 100644 index 0000000..dc99fcb --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/DevelopmentDatabaseWriteSmokeApp.cs @@ -0,0 +1,108 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +internal static class DevelopmentDatabaseWriteSmokeApp +{ + internal const int SuccessExitCode = 0; + internal const int FailureExitCode = 1; + internal const int NotAcknowledgedExitCode = 2; + internal const int OutcomeUnknownExitCode = 3; + + internal static async Task RunAsync( + IReadOnlyList arguments, + Func> createPlan, + TextWriter output, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(createPlan); + ArgumentNullException.ThrowIfNull(output); + + if (!SmokeCommandLine.TryParse(arguments, out var options)) + { + await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false); + return NotAcknowledgedExitCode; + } + + if (options.ShowHelp) + { + await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false); + await output.WriteLineAsync( + "No write occurs unless the exact development-database acknowledgement is present.") + .ConfigureAwait(false); + return SuccessExitCode; + } + + if (!options.IsWriteAcknowledged) + { + await output.WriteLineAsync( + "BLOCKED: development database writes were not acknowledged; no configuration was loaded and no database call was made.") + .ConfigureAwait(false); + await output.WriteLineAsync(SmokeCommandLine.Usage).ConfigureAwait(false); + return NotAcknowledgedExitCode; + } + + IReadOnlyList plan; + try + { + plan = createPlan(options.ConfigurationPath); + } + catch + { + await output.WriteLineAsync( + "FAIL: configuration or smoke-plan creation failed (details redacted).") + .ConfigureAwait(false); + return FailureExitCode; + } + + await output.WriteLineAsync( + "START: isolated development DB write/readback/cleanup smoke; credentials, SQL, identifiers, and values are redacted.") + .ConfigureAwait(false); + + foreach (var scenario in plan) + { + cancellationToken.ThrowIfCancellationRequested(); + await output.WriteLineAsync("RUN: " + scenario.Name).ConfigureAwait(false); + + DevelopmentDatabaseSmokeResult result; + try + { + result = await scenario.RunAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + await output.WriteLineAsync( + "FAIL: unclassified smoke failure; no retry was attempted (details redacted).") + .ConfigureAwait(false); + return FailureExitCode; + } + + if (result.Passed) + { + await output.WriteLineAsync( + "PASS: create/save, fresh readback, cleanup, and absence verification completed.") + .ConfigureAwait(false); + continue; + } + + await output.WriteLineAsync( + $"FAIL: stage={result.FailureStage}; failureClass={result.FailureClass}; cleanupAttempted={result.CleanupAttempted}; cleanupVerified={result.CleanupVerified}; details redacted.") + .ConfigureAwait(false); + if (result.OutcomeUnknown) + { + await output.WriteLineAsync( + "QUARANTINED: mutation outcome is unknown; no retry, cleanup mutation, or later scenario was issued. A uniquely prefixed record may require manual inspection.") + .ConfigureAwait(false); + return OutcomeUnknownExitCode; + } + + return FailureExitCode; + } + + await output.WriteLineAsync( + "PASS: every development DB write scenario was verified absent after cleanup.") + .ConfigureAwait(false); + return SuccessExitCode; + } +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/ExistingChildRowGuard.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/ExistingChildRowGuard.cs new file mode 100644 index 0000000..ce5db4e --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/ExistingChildRowGuard.cs @@ -0,0 +1,98 @@ +#nullable enable + +using System.Data; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +/// +/// The legacy deletion workflows remove child rows by numeric parent key. This +/// read-only guard proves a newly suggested key has no pre-existing orphaned +/// children before a smoke record can adopt that key. +/// +internal sealed class ExistingChildRowGuard +{ + private const string ResultColumn = "ROW_COUNT"; + + private readonly IDataQueryExecutor _executor; + + internal ExistingChildRowGuard(IDataQueryExecutor executor) + { + _executor = executor ?? throw new ArgumentNullException(nameof(executor)); + } + + internal Task EnsureNamedPlaylistChildrenAbsentAsync( + string programCode, + CancellationToken cancellationToken) => + EnsureZeroAsync( + DataSourceKind.Oracle, + "DB_WRITE_SMOKE_PLAYLIST_CHILD_PREFLIGHT", + "SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM PLAY_LIST WHERE PG_CODE = :parent_code", + programCode, + cancellationToken); + + internal Task EnsureThemeChildrenAbsentAsync( + ThemeMarket market, + string themeCode, + CancellationToken cancellationToken) => + market switch + { + ThemeMarket.Krx => EnsureZeroAsync( + DataSourceKind.Oracle, + "DB_WRITE_SMOKE_KRX_THEME_CHILD_PREFLIGHT", + "SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM SB_ITEM WHERE SB_M_CODE = :parent_code", + themeCode, + cancellationToken), + ThemeMarket.Nxt => EnsureZeroAsync( + DataSourceKind.MariaDb, + "DB_WRITE_SMOKE_NXT_THEME_CHILD_PREFLIGHT", + "SELECT CAST(COUNT(*) AS CHAR) ROW_COUNT FROM SB_ITEM WHERE SB_M_CODE = @parent_code", + themeCode, + cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(market)) + }; + + internal Task EnsureExpertChildrenAbsentAsync( + string expertCode, + CancellationToken cancellationToken) => + EnsureZeroAsync( + DataSourceKind.Oracle, + "DB_WRITE_SMOKE_EXPERT_CHILD_PREFLIGHT", + "SELECT TO_CHAR(COUNT(*)) ROW_COUNT FROM RECOMMEND_LIST WHERE RC_CODE = :parent_code", + expertCode, + cancellationToken); + + private async Task EnsureZeroAsync( + DataSourceKind source, + string queryName, + string sql, + string parentCode, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(parentCode); + var spec = new DataQuerySpec( + sql, + [new DataQueryParameter("parent_code", parentCode, DbType.String)]); + spec.ValidateFor(source); + var table = await _executor.ExecuteAsync( + source, + queryName, + spec, + cancellationToken).ConfigureAwait(false); + + if (table is null || + table.Columns.Count != 1 || + table.Rows.Count != 1 || + !string.Equals( + table.Columns[0].ColumnName, + ResultColumn, + StringComparison.Ordinal) || + table.Columns[0].DataType != typeof(string) || + table.Rows[0][0] is not string count || + !string.Equals(count, "0", StringComparison.Ordinal)) + { + throw new InvalidOperationException( + "A suggested numeric key has existing child rows or an invalid preflight result."); + } + } +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj new file mode 100644 index 0000000..1525a76 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + true + MBN_STOCK_WEBVIEW.DbWriteSmoke + MBN_STOCK_WEBVIEW.DbWriteSmoke + + + + + + + + diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/OwnedManualSmokeResidueRecovery.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/OwnedManualSmokeResidueRecovery.cs new file mode 100644 index 0000000..c93bc7f --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/OwnedManualSmokeResidueRecovery.cs @@ -0,0 +1,172 @@ +#nullable enable + +using System.Text.RegularExpressions; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +internal static partial class OwnedManualSmokeResidueRecovery +{ + internal const string RecoveryOption = "--reconcile-known-owned-manual-residue"; + + private static readonly IReadOnlyDictionary ScenarioCodes = + new Dictionary + { + [ManualFinancialScreenKind.RevenueComposition] = 'P', + [ManualFinancialScreenKind.GrowthMetrics] = 'G', + [ManualFinancialScreenKind.Sales] = 'S', + [ManualFinancialScreenKind.OperatingProfit] = 'O' + }; + + internal static bool IsRequested(IReadOnlyList arguments) => + arguments.Contains(RecoveryOption, StringComparer.Ordinal); + + internal static async Task RunAsync( + IReadOnlyList arguments, + TextWriter output, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(arguments); + ArgumentNullException.ThrowIfNull(output); + if (!TryParse(arguments, out var configurationPath)) + { + await output.WriteLineAsync( + "Usage: MBN_STOCK_WEBVIEW.DbWriteSmoke " + RecoveryOption + " " + + SmokeCommandLine.WriteAcknowledgement + + " [" + SmokeCommandLine.ConfigurationOption + " ]") + .ConfigureAwait(false); + return DevelopmentDatabaseWriteSmokeApp.NotAcknowledgedExitCode; + } + + try + { + var options = new DatabaseOptionsLoader().Load(configurationPath); + options.Resilience.MaximumRetryCount = 0; + options.Resilience.InitialRetryDelayMilliseconds = 0; + var runtime = DatabaseRuntime.Create(options); + var service = new LegacyManualFinancialScreenService( + runtime.Executor, + new OracleManualFinancialMutationExecutor( + runtime.ConnectionFactory, + options.Resilience)); + var prefix = SmokeRunIdentity.Prefix + + DateTimeOffset.UtcNow.ToString("yyMMdd", System.Globalization.CultureInfo.InvariantCulture); + var total = 0; + + await output.WriteLineAsync( + "START: reconcile only the closed, current-UTC-date manual smoke namespace; details redacted.") + .ConfigureAwait(false); + foreach (var (screen, scenarioCode) in ScenarioCodes) + { + var result = await service.SearchAsync( + screen, + prefix, + LegacyManualFinancialScreenService.MaximumResults, + cancellationToken) + .ConfigureAwait(false); + var owned = result.Items.Where(item => + IsOwned(item.Record.Identity.StockName, prefix, scenarioCode)) + .ToArray(); + if (result.IsTruncated || owned.Length > 1) + { + await output.WriteLineAsync( + "BLOCKED: the owned residue set was not singular and complete; writesAttempted=false.") + .ConfigureAwait(false); + return DevelopmentDatabaseWriteSmokeApp.FailureExitCode; + } + + if (owned.Length == 0) + { + continue; + } + + await service.DeleteAsync(owned[0], cancellationToken).ConfigureAwait(false); + total++; + try + { + _ = await service.GetAsync( + owned[0].Record.Identity, + cancellationToken) + .ConfigureAwait(false); + } + catch (ManualFinancialNotFoundException) + { + continue; + } + + await output.WriteLineAsync( + "FAIL: an owned residue remained after one delete; no retry was issued.") + .ConfigureAwait(false); + return DevelopmentDatabaseWriteSmokeApp.FailureExitCode; + } + + await output.WriteLineAsync( + $"PASS: reconciled={total}; every owned residue is absent; no retry was issued.") + .ConfigureAwait(false); + return DevelopmentDatabaseWriteSmokeApp.SuccessExitCode; + } + catch (ManualFinancialMutationException exception) + { + await output.WriteLineAsync( + "FAIL: failureClass=" + RedactedDatabaseFailureClassifier.Classify(exception) + + "; outcomeUnknown=" + exception.OutcomeUnknown.ToString().ToLowerInvariant() + + "; no retry was issued.") + .ConfigureAwait(false); + return exception.OutcomeUnknown + ? DevelopmentDatabaseWriteSmokeApp.OutcomeUnknownExitCode + : DevelopmentDatabaseWriteSmokeApp.FailureExitCode; + } + catch (Exception exception) + { + await output.WriteLineAsync( + "FAIL: failureClass=" + RedactedDatabaseFailureClassifier.Classify(exception) + + "; no retry was issued; details redacted.") + .ConfigureAwait(false); + return DevelopmentDatabaseWriteSmokeApp.FailureExitCode; + } + } + + private static bool IsOwned(string value, string prefix, char scenarioCode) => + value.Length == 20 && + value.StartsWith(prefix, StringComparison.Ordinal) && + value[^1] == scenarioCode && + OwnedNamePattern().IsMatch(value); + + private static bool TryParse( + IReadOnlyList arguments, + out string? configurationPath) + { + configurationPath = null; + var recovery = false; + var acknowledged = false; + for (var index = 0; index < arguments.Count; index++) + { + switch (arguments[index]) + { + case RecoveryOption when !recovery: + recovery = true; + break; + case SmokeCommandLine.WriteAcknowledgement when !acknowledged: + acknowledged = true; + break; + case SmokeCommandLine.ConfigurationOption when configurationPath is null && + index + 1 < arguments.Count: + configurationPath = arguments[++index]; + if (string.IsNullOrWhiteSpace(configurationPath)) + { + return false; + } + + break; + default: + return false; + } + } + + return recovery && acknowledged; + } + + [GeneratedRegex("^MBW[0-9]{6}[0-9A-F]{10}[PGSO]$", RegexOptions.CultureInvariant)] + private static partial Regex OwnedNamePattern(); +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/ProductionDevelopmentDatabaseSmokePlan.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/ProductionDevelopmentDatabaseSmokePlan.cs new file mode 100644 index 0000000..806cc68 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/ProductionDevelopmentDatabaseSmokePlan.cs @@ -0,0 +1,559 @@ +#nullable enable + +using MBN_STOCK_WEBVIEW.Core.Playout.Scenes; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MMoneyCoderSharp.Data; + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +internal static class ProductionDevelopmentDatabaseSmokePlan +{ + internal static IReadOnlyList Create( + string? configurationPath) + { + var options = new DatabaseOptionsLoader().Load(configurationPath); + + // This executable never inherits the production read retry count. A + // timeout is surfaced once so the harness cannot conceal an uncertain + // state transition behind another attempt. + options.Resilience.MaximumRetryCount = 0; + options.Resilience.InitialRetryDelayMilliseconds = 0; + var runtime = DatabaseRuntime.Create(options); + + var manualMutation = new OracleManualFinancialMutationExecutor( + runtime.ConnectionFactory, + options.Resilience); + var namedMutation = new OracleNamedPlaylistMutationExecutor( + runtime.ConnectionFactory, + options.Resilience); + var catalogMutation = new OperatorCatalogMutationExecutor( + runtime.ConnectionFactory, + options.Resilience); + + var manual = new LegacyManualFinancialScreenService( + runtime.Executor, + manualMutation); + var named = new LegacyNamedPlaylistPersistenceService( + runtime.Executor, + namedMutation); + var themePersistence = new LegacyThemeCatalogPersistenceService( + runtime.Executor, + catalogMutation); + var themeSelection = new LegacyThemeSelectionService(runtime.Executor); + var expertPersistence = new LegacyExpertCatalogPersistenceService( + runtime.Executor, + catalogMutation); + var expertSelection = new LegacyExpertSelectionService(runtime.Executor); + var childGuard = new ExistingChildRowGuard(runtime.Executor); + + var run = SmokeRunIdentity.Create(DateTimeOffset.UtcNow, Guid.NewGuid()); + return + [ + CreateManualScenario( + manual, + CreateManualRecord(ManualFinancialScreenKind.RevenueComposition, run.For("G_PIE"))), + CreateManualScenario( + manual, + CreateManualRecord(ManualFinancialScreenKind.GrowthMetrics, run.For("G_GROW"))), + CreateManualScenario( + manual, + CreateManualRecord(ManualFinancialScreenKind.Sales, run.For("G_SELL"))), + CreateManualScenario( + manual, + CreateManualRecord(ManualFinancialScreenKind.OperatingProfit, run.For("G_PROFIT"))), + CreateNamedPlaylistScenario(named, childGuard, run.For("PLAYLIST")), + CreateThemeScenario( + themePersistence, + themeSelection, + childGuard, + ThemeMarket.Krx, + run.For("THEME_KRX")), + CreateThemeScenario( + themePersistence, + themeSelection, + childGuard, + ThemeMarket.Nxt, + run.For("THEME_NXT")), + CreateExpertScenario( + expertPersistence, + expertSelection, + childGuard, + run.For("EXPERT")) + ]; + } + + private static DevelopmentDatabaseSmokeScenario CreateManualScenario( + IManualFinancialScreenService service, + ManualFinancialRecord expectedRecord) + { + var identity = expectedRecord.Identity; + ManualFinancialSnapshot? freshSnapshot = null; + return new DevelopmentDatabaseSmokeScenario( + "GraphE/" + identity.Screen, + async cancellationToken => + { + var search = await service.SearchAsync( + identity.Screen, + identity.StockName, + 10, + cancellationToken).ConfigureAwait(false); + if (search.IsTruncated || search.Items.Any(item => + string.Equals( + item.Record.Identity.StockName, + identity.StockName, + StringComparison.Ordinal))) + { + throw Collision("GraphE preflight did not prove absence."); + } + }, + cancellationToken => IgnoreResultAsync( + service.CreateAsync(expectedRecord, cancellationToken)), + async cancellationToken => + { + freshSnapshot = await service.GetAsync(identity, cancellationToken) + .ConfigureAwait(false); + VerifyManualRecord(expectedRecord, freshSnapshot); + }, + save: null, + verifySave: null, + async cancellationToken => + { + freshSnapshot ??= await service.GetAsync(identity, cancellationToken) + .ConfigureAwait(false); + await service.DeleteAsync(freshSnapshot, cancellationToken).ConfigureAwait(false); + }, + cancellationToken => EnsureManualAbsentAsync(service, identity, cancellationToken), + IsOutcomeUnknown); + } + + private static DevelopmentDatabaseSmokeScenario CreateNamedPlaylistScenario( + INamedPlaylistPersistenceService service, + ExistingChildRowGuard childGuard, + string title) + { + string? programCode = null; + var savedItem = new NamedPlaylistStoredItem( + 0, + true, + new LegacySceneSelection( + "KOSPI", + "SMOKE", + "PLATE", + "CURRENT", + string.Empty), + new NamedPlaylistPageState(1, 1)); + + return new DevelopmentDatabaseSmokeScenario( + "AList/PList named playlist", + async cancellationToken => + { + programCode = await service.SuggestNextProgramCodeAsync(cancellationToken) + .ConfigureAwait(false); + var list = await service.ListAsync( + LegacyNamedPlaylistPersistenceService.MaximumDefinitions, + cancellationToken).ConfigureAwait(false); + if (list.IsTruncated || list.Items.Any(item => + string.Equals(item.Title, title, StringComparison.Ordinal))) + { + throw Collision("Named-playlist title absence was not proven."); + } + + await EnsureNamedPlaylistAbsentAsync( + service, + programCode, + cancellationToken).ConfigureAwait(false); + await childGuard.EnsureNamedPlaylistChildrenAbsentAsync( + programCode, + cancellationToken).ConfigureAwait(false); + }, + cancellationToken => service.CreateDefinitionAsync( + Required(programCode), + title, + cancellationToken), + async cancellationToken => + { + var document = await service.LoadAsync( + Required(programCode), + cancellationToken).ConfigureAwait(false); + if (!string.Equals( + document.Definition.Title, + title, + StringComparison.Ordinal) || + document.Items.Count != 0) + { + throw Verification("Named-playlist create readback did not match."); + } + }, + cancellationToken => service.ReplaceItemsAsync( + Required(programCode), + [savedItem], + cancellationToken), + async cancellationToken => + { + var document = await service.LoadAsync( + Required(programCode), + cancellationToken).ConfigureAwait(false); + if (!string.Equals( + document.Definition.Title, + title, + StringComparison.Ordinal) || + document.Items.Count != 1 || + document.Items[0] != savedItem) + { + throw Verification("Named-playlist save readback did not match."); + } + }, + cancellationToken => service.DeleteAsync( + Required(programCode), + cancellationToken), + async cancellationToken => + { + await EnsureNamedPlaylistAbsentAsync( + service, + Required(programCode), + cancellationToken).ConfigureAwait(false); + var list = await service.ListAsync( + LegacyNamedPlaylistPersistenceService.MaximumDefinitions, + cancellationToken).ConfigureAwait(false); + if (list.IsTruncated || list.Items.Any(item => + string.Equals(item.Title, title, StringComparison.Ordinal))) + { + throw Verification("Named-playlist cleanup absence was not proven."); + } + }, + IsOutcomeUnknown); + } + + private static DevelopmentDatabaseSmokeScenario CreateThemeScenario( + IThemeCatalogPersistenceService persistence, + IThemeSelectionService selection, + ExistingChildRowGuard childGuard, + ThemeMarket market, + string title) + { + string? themeCode = null; + ThemeSelectionIdentity? freshIdentity = null; + var session = market == ThemeMarket.Krx + ? ThemeSession.NotApplicable + : ThemeSession.PreMarket; + + return new DevelopmentDatabaseSmokeScenario( + "ThemeA/" + market, + async cancellationToken => + { + await EnsureThemeAbsentAsync(selection, title, cancellationToken) + .ConfigureAwait(false); + themeCode = await persistence.SuggestNextCodeAsync(market, cancellationToken) + .ConfigureAwait(false); + await childGuard.EnsureThemeChildrenAbsentAsync( + market, + themeCode, + cancellationToken).ConfigureAwait(false); + }, + cancellationToken => IgnoreResultAsync( + persistence.CreateAsync( + new ThemeCatalogDefinition( + market, + Required(themeCode), + title, + []), + cancellationToken)), + async cancellationToken => + { + var search = await selection.SearchAsync( + title, + ThemeSession.PreMarket, + LegacyThemeSelectionService.MaximumResults, + cancellationToken).ConfigureAwait(false); + if (search.IsTruncated) + { + throw Verification("Theme readback was truncated."); + } + + var matches = search.Items.Where(item => + string.Equals( + item.Identity.ThemeTitle, + title, + StringComparison.Ordinal)).ToArray(); + if (matches.Length != 1 || + matches[0].Identity.Market != market || + !string.Equals( + matches[0].Identity.ThemeCode, + themeCode, + StringComparison.Ordinal)) + { + throw Verification("Theme create readback did not match."); + } + + freshIdentity = matches[0].Identity with { Session = session }; + var preview = await selection.PreviewAsync( + freshIdentity, + LegacyThemeSelectionService.MaximumPreviewItems, + cancellationToken).ConfigureAwait(false); + if (preview.Items.Count != 0 || preview.IsTruncated) + { + throw Verification("Theme preview readback did not match."); + } + }, + save: null, + verifySave: null, + cancellationToken => IgnoreResultAsync( + persistence.DeleteAsync( + freshIdentity ?? new ThemeSelectionIdentity( + market, + session, + Required(themeCode), + title), + cancellationToken)), + cancellationToken => EnsureThemeAbsentAsync(selection, title, cancellationToken), + IsOutcomeUnknown); + } + + private static DevelopmentDatabaseSmokeScenario CreateExpertScenario( + IExpertCatalogPersistenceService persistence, + IExpertSelectionService selection, + ExistingChildRowGuard childGuard, + string name) + { + string? expertCode = null; + ExpertSelectionIdentity? freshIdentity = null; + return new DevelopmentDatabaseSmokeScenario( + "EList expert catalog", + async cancellationToken => + { + await EnsureExpertAbsentAsync(selection, name, cancellationToken) + .ConfigureAwait(false); + expertCode = await persistence.SuggestNextCodeAsync(cancellationToken) + .ConfigureAwait(false); + await childGuard.EnsureExpertChildrenAbsentAsync( + expertCode, + cancellationToken).ConfigureAwait(false); + }, + cancellationToken => IgnoreResultAsync( + persistence.CreateAsync( + new ExpertCatalogDefinition( + new ExpertSelectionIdentity(Required(expertCode), name), + []), + cancellationToken)), + async cancellationToken => + { + var search = await selection.SearchAsync( + name, + LegacyExpertSelectionService.MaximumResults, + cancellationToken).ConfigureAwait(false); + if (search.IsTruncated) + { + throw Verification("Expert readback was truncated."); + } + + var matches = search.Items.Where(item => + string.Equals( + item.Identity.ExpertName, + name, + StringComparison.Ordinal)).ToArray(); + if (matches.Length != 1 || + !string.Equals( + matches[0].Identity.ExpertCode, + expertCode, + StringComparison.Ordinal)) + { + throw Verification("Expert create readback did not match."); + } + + freshIdentity = matches[0].Identity; + var preview = await selection.PreviewAsync( + freshIdentity, + LegacyExpertSelectionService.MaximumPreviewItems, + cancellationToken).ConfigureAwait(false); + if (preview.Items.Count != 0 || preview.IsTruncated) + { + throw Verification("Expert preview readback did not match."); + } + }, + save: null, + verifySave: null, + cancellationToken => IgnoreResultAsync( + persistence.DeleteAsync( + freshIdentity ?? new ExpertSelectionIdentity( + Required(expertCode), + name), + cancellationToken)), + cancellationToken => EnsureExpertAbsentAsync(selection, name, cancellationToken), + IsOutcomeUnknown); + } + + private static ManualFinancialRecord CreateManualRecord( + ManualFinancialScreenKind screen, + string stockName) + { + var identity = new ManualFinancialIdentity(screen, stockName); + return screen switch + { + ManualFinancialScreenKind.RevenueComposition => + new ManualRevenueCompositionRecord( + identity, + "2026-07", + [new("SMOKE", "100", 100), null, null, null, null]), + ManualFinancialScreenKind.GrowthMetrics => + new ManualGrowthMetricsRecord( + identity, + new(1, 2, 3, 4), + new(5, 6, 7, 8), + new(9, 10, 11, 12), + new(13, 14, 15, 16), + new("P1", "P2", "P3", "P4")), + ManualFinancialScreenKind.Sales => + new ManualSalesRecord(identity, SixQuarters()), + ManualFinancialScreenKind.OperatingProfit => + new ManualOperatingProfitRecord(identity, SixQuarters()), + _ => throw new ArgumentOutOfRangeException(nameof(screen)) + }; + } + + private static IReadOnlyList SixQuarters() => + [ + new("Q1", 10), + new("Q2", -20), + new("Q3", 30), + new("Q4", 40), + new("Q5", 50), + new("Q6", 60) + ]; + + private static void VerifyManualRecord( + ManualFinancialRecord expected, + ManualFinancialSnapshot actual) + { + if (string.IsNullOrWhiteSpace(actual.RowVersion) || + actual.Record.Identity != expected.Identity) + { + throw Verification("GraphE identity or row version did not match."); + } + + var matches = (expected, actual.Record) switch + { + (ManualRevenueCompositionRecord left, ManualRevenueCompositionRecord right) => + string.Equals(left.BaseDate, right.BaseDate, StringComparison.Ordinal) && + left.Slices.SequenceEqual(right.Slices), + (ManualGrowthMetricsRecord left, ManualGrowthMetricsRecord right) => + left.SalesGrowth == right.SalesGrowth && + left.OperatingProfitGrowth == right.OperatingProfitGrowth && + left.NetAssetGrowth == right.NetAssetGrowth && + left.NetIncomeGrowth == right.NetIncomeGrowth && + left.Periods == right.Periods, + (ManualSalesRecord left, ManualSalesRecord right) => + left.Quarters.SequenceEqual(right.Quarters), + (ManualOperatingProfitRecord left, ManualOperatingProfitRecord right) => + left.Quarters.SequenceEqual(right.Quarters), + _ => false + }; + if (!matches) + { + throw Verification("GraphE create readback did not match."); + } + } + + private static async Task EnsureManualAbsentAsync( + IManualFinancialScreenService service, + ManualFinancialIdentity identity, + CancellationToken cancellationToken) + { + try + { + _ = await service.GetAsync(identity, cancellationToken).ConfigureAwait(false); + } + catch (ManualFinancialNotFoundException) + { + return; + } + + throw Verification("GraphE record still exists."); + } + + private static async Task EnsureNamedPlaylistAbsentAsync( + INamedPlaylistPersistenceService service, + string programCode, + CancellationToken cancellationToken) + { + try + { + _ = await service.LoadAsync(programCode, cancellationToken).ConfigureAwait(false); + } + catch (NamedPlaylistNotFoundException) + { + return; + } + + throw Verification("Named playlist still exists."); + } + + private static async Task EnsureThemeAbsentAsync( + IThemeSelectionService selection, + string title, + CancellationToken cancellationToken) + { + var search = await selection.SearchAsync( + title, + ThemeSession.PreMarket, + LegacyThemeSelectionService.MaximumResults, + cancellationToken).ConfigureAwait(false); + if (search.IsTruncated || search.Items.Any(item => + string.Equals( + item.Identity.ThemeTitle, + title, + StringComparison.Ordinal))) + { + throw Collision("Theme absence was not proven."); + } + } + + private static async Task EnsureExpertAbsentAsync( + IExpertSelectionService selection, + string name, + CancellationToken cancellationToken) + { + var search = await selection.SearchAsync( + name, + LegacyExpertSelectionService.MaximumResults, + cancellationToken).ConfigureAwait(false); + if (search.IsTruncated || search.Items.Any(item => + string.Equals( + item.Identity.ExpertName, + name, + StringComparison.Ordinal))) + { + throw Collision("Expert absence was not proven."); + } + } + + private static async Task IgnoreResultAsync(Task operation) + { + _ = await operation.ConfigureAwait(false); + } + + private static string Required(string? value) => + value ?? throw new InvalidOperationException("Smoke preflight did not produce an identity."); + + private static bool IsOutcomeUnknown(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + if (exception is ManualFinancialMutationException manual && manual.OutcomeUnknown || + exception is NamedPlaylistMutationException named && named.OutcomeUnknown || + exception is OperatorCatalogMutationException catalog && catalog.OutcomeUnknown) + { + return true; + } + + if (exception is AggregateException aggregate && + aggregate.InnerExceptions.Any(IsOutcomeUnknown)) + { + return true; + } + + return exception.InnerException is not null && IsOutcomeUnknown(exception.InnerException); + } + + private static InvalidOperationException Collision(string message) => new(message); + + private static InvalidOperationException Verification(string message) => new(message); +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Program.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Program.cs new file mode 100644 index 0000000..4f2258c --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Program.cs @@ -0,0 +1,21 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +internal static class Program +{ + private static async Task Main(string[] args) + { + if (OwnedManualSmokeResidueRecovery.IsRequested(args)) + { + return await OwnedManualSmokeResidueRecovery.RunAsync(args, Console.Out) + .ConfigureAwait(false); + } + + return await DevelopmentDatabaseWriteSmokeApp.RunAsync( + args, + ProductionDevelopmentDatabaseSmokePlan.Create, + Console.Out) + .ConfigureAwait(false); + } +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Properties/AssemblyInfo.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..2560e85 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("MBN_STOCK_WEBVIEW.DbWriteSmoke.Tests")] diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/README.md b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/README.md new file mode 100644 index 0000000..f6ea5d7 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/README.md @@ -0,0 +1,49 @@ +# Development database write smoke + +This executable is deliberately separate from the read-only `DbSmoke` tool. It +does not load database configuration or make a database call unless the exact +write acknowledgement is present: + +```powershell +dotnet run --project tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj -- ` + --i-understand-this-writes-development-database +``` + +Use `--configuration ` only when the normal LocalAppData configuration is +not the intended development configuration. Credentials, SQL, generated +identities, and database values are never printed. + +The plan covers every migrated database writer: + +- all four Oracle GraphE `INPUT_*` screens; +- Oracle AList/PList definition creation and item replacement; +- ThemeA KRX (Oracle) and NXT (MariaDB) creation; +- EList expert creation (Oracle). + +Each scenario uses a legacy-column-safe 20-character +`MBW<40-bit nonce>` name, +proves that name is absent, performs each production-service mutation once, +reads the row back through the production read service, deletes it in `finally`, +and verifies absence. Numeric legacy keys cannot contain the prefix, so their +production next-code service supplies the numeric key and the conditional insert +still rejects a concurrent collision without overwriting an existing row. A +bound, read-only child-row preflight also rejects keys that have legacy orphaned +`PLAY_LIST`, `SB_ITEM`, or `RECOMMEND_LIST` rows, so cleanup cannot delete an +older row. + +Both read and write retries are disabled. If a mutation outcome is unknown, the +tool stops immediately and issues no retry, cleanup mutation, or later scenario. +Its redacted output then reports that the uniquely prefixed row may require +manual inspection. + +If a known-committed manual smoke row could not be parsed or deleted, reconcile +only the tool-owned current-UTC-date namespace with: + +```powershell +dotnet run --project tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/MBN_STOCK_WEBVIEW.DbWriteSmoke.csproj -- ` + --reconcile-known-owned-manual-residue ` + --i-understand-this-writes-development-database +``` + +The recovery refuses a truncated or non-singular per-table match and never +retries a delete. diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/RedactedDatabaseFailureClassifier.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/RedactedDatabaseFailureClassifier.cs new file mode 100644 index 0000000..9c3c999 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/RedactedDatabaseFailureClassifier.cs @@ -0,0 +1,64 @@ +#nullable enable + +using MMoneyCoderSharp.Data; +using MBN_STOCK_WEBVIEW.Infrastructure; +using MySqlConnector; +using Oracle.ManagedDataAccess.Client; + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +/// +/// Emits only a provider/type classification. Messages, SQL, credentials, +/// identifiers, and values deliberately never cross this boundary. +/// +internal static class RedactedDatabaseFailureClassifier +{ + internal static string Classify(Exception exception) + { + ArgumentNullException.ThrowIfNull(exception); + foreach (var current in Flatten(exception)) + { + switch (current) + { + case OracleException oracle: + return "Oracle-" + oracle.Number.ToString( + System.Globalization.CultureInfo.InvariantCulture); + case MySqlException mariaDb: + return "MariaDb-" + ((int)mariaDb.ErrorCode).ToString( + System.Globalization.CultureInfo.InvariantCulture); + case DatabaseOperationException database: + return database.DataSource + "-" + + (database.ProviderErrorCode?.ToString( + System.Globalization.CultureInfo.InvariantCulture) ?? "none"); + case ManualFinancialMutationRejectedException rejected: + return "ManualRejected-" + rejected.Reason; + case OperatorCatalogConflictException conflict: + return "CatalogConflict-" + conflict.Kind + "-" + conflict.CommandKind; + } + } + + return exception.GetType().Name; + } + + private static IEnumerable Flatten(Exception exception) + { + var pending = new Stack(); + var visited = new HashSet(ReferenceEqualityComparer.Instance); + pending.Push(exception); + while (pending.TryPop(out var current) && visited.Add(current)) + { + yield return current; + if (current is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions.Reverse()) + { + pending.Push(inner); + } + } + else if (current.InnerException is not null) + { + pending.Push(current.InnerException); + } + } + } +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeCommandLine.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeCommandLine.cs new file mode 100644 index 0000000..450ef76 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeCommandLine.cs @@ -0,0 +1,65 @@ +#nullable enable + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +internal sealed record SmokeCommandOptions( + bool ShowHelp, + bool IsWriteAcknowledged, + string? ConfigurationPath); + +internal static class SmokeCommandLine +{ + internal const string WriteAcknowledgement = + "--i-understand-this-writes-development-database"; + internal const string ConfigurationOption = "--configuration"; + + internal static bool TryParse( + IReadOnlyList arguments, + out SmokeCommandOptions options) + { + ArgumentNullException.ThrowIfNull(arguments); + options = new SmokeCommandOptions(false, false, null); + if (arguments.Count == 0) + { + return true; + } + + if (arguments.Count == 1 && + arguments[0] is "--help" or "-h" or "/?") + { + options = options with { ShowHelp = true }; + return true; + } + + var acknowledged = false; + string? configurationPath = null; + for (var index = 0; index < arguments.Count; index++) + { + var argument = arguments[index]; + switch (argument) + { + case WriteAcknowledgement when !acknowledged: + acknowledged = true; + break; + case ConfigurationOption when configurationPath is null && + index + 1 < arguments.Count: + configurationPath = arguments[++index]; + if (string.IsNullOrWhiteSpace(configurationPath)) + { + return false; + } + + break; + default: + return false; + } + } + + options = new SmokeCommandOptions(false, acknowledged, configurationPath); + return true; + } + + internal static string Usage => + "Usage: MBN_STOCK_WEBVIEW.DbWriteSmoke " + WriteAcknowledgement + + " [" + ConfigurationOption + " ]"; +} diff --git a/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeRunIdentity.cs b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeRunIdentity.cs new file mode 100644 index 0000000..1371b95 --- /dev/null +++ b/tools/MBN_STOCK_WEBVIEW.DbWriteSmoke/SmokeRunIdentity.cs @@ -0,0 +1,55 @@ +#nullable enable + +using System.Globalization; + +namespace MBN_STOCK_WEBVIEW.DbWriteSmoke; + +internal sealed record SmokeRunIdentity(string BaseName) +{ + internal const string Prefix = "MBW"; + + private static readonly IReadOnlyDictionary ScenarioCodes = + new Dictionary(StringComparer.Ordinal) + { + ["G_PIE"] = 'P', + ["G_GROW"] = 'G', + ["G_SELL"] = 'S', + ["G_PROFIT"] = 'O', + ["PLAYLIST"] = 'L', + ["THEME_KRX"] = 'K', + ["THEME_NXT"] = 'N', + ["EXPERT"] = 'E' + }; + + internal static SmokeRunIdentity Create(DateTimeOffset utcNow, Guid nonce) + { + if (nonce == Guid.Empty) + { + throw new ArgumentException("A non-empty smoke nonce is required.", nameof(nonce)); + } + + var utc = utcNow.ToUniversalTime(); + var timestamp = utc.ToString("yyMMdd", CultureInfo.InvariantCulture); + var suffix = nonce.ToString("N", CultureInfo.InvariantCulture)[..10] + .ToUpperInvariant(); + return new SmokeRunIdentity(Prefix + timestamp + suffix); + } + + internal string For(string suffix) + { + if (!ScenarioCodes.TryGetValue(suffix, out var scenarioCode)) + { + throw new ArgumentException("The smoke identity suffix is invalid.", nameof(suffix)); + } + + var value = BaseName + scenarioCode; + if (value.Length != 20 || + value.Any(static character => + !(character is >= 'A' and <= 'Z' or >= '0' and <= '9'))) + { + throw new InvalidOperationException("The smoke identity is outside its closed boundary."); + } + + return value; + } +}