diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:08:53 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 22:08:53 +0800 |
| commit | 4c60174fc66927db5536bc2fe06574c753140055 (patch) | |
| tree | c18fb8650d097f584cd8e141903cff2ffae0d0fd /src | |
| parent | 722ec6c19c0c64ea5348c6bc3aefc0061bd96307 (diff) | |
| download | fund-lab-4c60174fc66927db5536bc2fe06574c753140055.tar.gz | |
Add dividend history visibility (3d-9a)
Expose a deterministic source key on booked dividend records and surface a read-only dividend history panel with empty state, backed by API tests and browser QA assertions.
Diffstat (limited to 'src')
| -rw-r--r-- | src/FundLab.Api/App.fs | 2 | ||||
| -rw-r--r-- | src/FundLab.Api/Persistence.fs | 2 | ||||
| -rw-r--r-- | src/FundLab.Domain/Dividend.fs | 5 | ||||
| -rw-r--r-- | src/FundLab.Web/App.fs | 200 | ||||
| -rw-r--r-- | src/FundLab.Web/src/api.js | 4 | ||||
| -rw-r--r-- | src/FundLab.Web/src/styles.css | 14 |
6 files changed, 226 insertions, 1 deletions
diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index b5b2064..38debc3 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -156,6 +156,7 @@ type DividendResponse = navDate: string dps: string mode: string + sourceKey: string status: string grossCash: string option creditedUnits: string option @@ -613,6 +614,7 @@ module App = navDate = dateText record.NavDate dps = decimalText record.Dps mode = DividendPolicy.modeText record.Mode + sourceKey = DividendPolicy.schemeKey record.FundId record.InstrumentCode record.NavDate record.Mode status = record.Status grossCash = record.GrossCash |> Option.map cashText creditedUnits = record.CreditedUnits |> Option.map decimalText diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index e252d0a..973544f 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -1922,7 +1922,7 @@ type FundRepository(connectionString: string) = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(payload))) let dividendSchemeKey (fundId: Guid) (command: DividendCommand) = - sprintf "dividend-scheme:%O:%s:%s:%s" fundId command.InstrumentCode (command.NavDate.ToString("yyyy-MM-dd")) (DividendPolicy.modeText command.Mode) + DividendPolicy.schemeKey fundId command.InstrumentCode command.NavDate command.Mode let validateDividendCommand (today: DateOnly) (command: DividendCommand) = if String.IsNullOrWhiteSpace command.InstrumentCode then diff --git a/src/FundLab.Domain/Dividend.fs b/src/FundLab.Domain/Dividend.fs index 005a1c5..5a4259b 100644 --- a/src/FundLab.Domain/Dividend.fs +++ b/src/FundLab.Domain/Dividend.fs @@ -15,6 +15,11 @@ module DividendPolicy = | Cash -> "cash" | Reinvest -> "reinvest" + /// Deterministic provenance key of a booked scheme. Shared by the writer (idempotency + /// identity) and the read model (历史列表的“来源 schema key”), so both stay in lockstep. + let schemeKey (fundId: Guid) (instrumentCode: string) (navDate: DateOnly) (mode: DividendMode) : string = + sprintf "dividend-scheme:%O:%s:%s:%s" fundId instrumentCode (navDate.ToString("yyyy-MM-dd")) (modeText mode) + let parseMode (text: string) : DividendMode option = match text with | null -> None diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index d6e44d9..6ce48f0 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -213,6 +213,25 @@ type RawRedemption = confirmedCostReleased: obj } +type RawDividend = + { + id: string + fundId: string + instrumentCode: string + navDate: string + dps: string + mode: string + sourceKey: string + status: string + grossCash: obj + creditedUnits: obj + creditedInvested: obj + orderId: obj + pendingReason: obj + isSynthetic: bool + createdAt: string + } + type RawPositions = { availableCash: string @@ -391,6 +410,24 @@ type RedemptionDetail = confirmedCostReleased: string option } +type DividendRecord = + { + id: string + instrumentCode: string + navDate: string + dps: string + mode: string + sourceKey: string + status: string + grossCash: string option + creditedUnits: string option + creditedInvested: string option + orderId: string option + pendingReason: string option + isSynthetic: bool + createdAt: string + } + type Positions = { availableCash: string @@ -435,6 +472,9 @@ module Api = [<Import("confirmRedemption", "./src/api.js")>] let confirmRedemption (token: string) (fundId: string) (orderId: string) (idempotencyKey: string) : JS.Promise<RawRedemption> = jsNative + [<Import("getDividends", "./src/api.js")>] + let getDividends (token: string) (fundId: string) : JS.Promise<RawDividend array> = jsNative + [<Import("createCapitalDeposit", "./src/api.js")>] let createCapitalDeposit (token: string) (fundId: string) (payload: CreateCapitalPayload) : JS.Promise<obj> = jsNative @@ -579,6 +619,24 @@ module Api = confirmedCostReleased = decodeOptionalText raw.confirmedCostReleased } + let decodeDividend (raw: RawDividend) : DividendRecord = + { + id = raw.id + instrumentCode = raw.instrumentCode + navDate = raw.navDate + dps = raw.dps + mode = raw.mode + sourceKey = raw.sourceKey + status = raw.status + grossCash = decodeOptionalText raw.grossCash + creditedUnits = decodeOptionalText raw.creditedUnits + creditedInvested = decodeOptionalText raw.creditedInvested + orderId = decodeOptionalText raw.orderId + pendingReason = decodeOptionalText raw.pendingReason + isSynthetic = raw.isSynthetic + createdAt = raw.createdAt + } + type Model = { token: string @@ -645,6 +703,9 @@ type Model = rebalExecuteInFlight: bool rebalPlans: RebalancePlan list rebalResult: RebalanceOutcome list option + dividendReadSeq: int + dividendInFlight: bool + dividends: DividendRecord list error: string option } @@ -720,6 +781,9 @@ type Msg = | RebalanceExecuteRequested of planId: string | RebalanceExecuted of requestId: int * result: RawRebalanceExecution | RebalanceExecuteFailed of requestId: int * message: string + | DividendsReadRequested + | DividendsReadCompleted of requestId: int * records: RawDividend array + | DividendsReadFailed of requestId: int * message: string let defaultInitialUnitNav = "1.00000000" @@ -815,6 +879,9 @@ let init () = rebalExecuteInFlight = false rebalPlans = [] rebalResult = None + dividendReadSeq = 0 + dividendInFlight = false + dividends = [] error = None } @@ -950,6 +1017,13 @@ let private executeRebalanceCommand token fundId planId requestId = (fun result -> RebalanceExecuted(requestId, result)) (fun error -> RebalanceExecuteFailed(requestId, errorText error)) +let private readDividendsCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getDividends token fundId) + () + (fun records -> DividendsReadCompleted(requestId, records)) + (fun error -> DividendsReadFailed(requestId, errorText error)) + let update message model = match message with | TokenChanged token -> @@ -1018,6 +1092,9 @@ let update message model = rebalExecuteInFlight = false rebalPlans = [] rebalResult = None + dividendReadSeq = model.dividendReadSeq + 1 + dividendInFlight = false + dividends = [] error = None }, Cmd.none @@ -1212,6 +1289,9 @@ let update message model = rebalExecuteInFlight = false rebalPlans = [] rebalResult = None + dividendReadSeq = model.dividendReadSeq + 1 + dividendInFlight = false + dividends = [] error = None }, Cmd.ofMsg OrdersReadRequested @@ -1855,6 +1935,36 @@ let update message model = { model with rebalExecuteInFlight = false; error = Some message }, Cmd.none else model, Cmd.none + | DividendsReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.dividendReadSeq + 1 + + { + model with + dividendReadSeq = requestId + dividendInFlight = true + error = None + }, + readDividendsCommand model.token fund.id requestId + | Some _ -> { model with error = Some "请输入 API token" }, Cmd.none + | None -> model, Cmd.none + | DividendsReadCompleted (requestId, records) -> + if requestId = model.dividendReadSeq then + { + model with + dividends = records |> Array.map Api.decodeDividend |> Array.toList + dividendInFlight = false + error = None + }, + Cmd.none + else + model, Cmd.none + | DividendsReadFailed (requestId, message) -> + if requestId = model.dividendReadSeq then + { model with dividendInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none let private navText (text: string) = @@ -2851,6 +2961,95 @@ let private rebalancePanel model dispatch = ] +let private dividendModeText (mode: string) = + match mode with + | "reinvest" -> "红利再投资" + | _ -> "现金" + +let private dividendStatusText (status: string) = + match status with + | "cash_credited" + | "succeeded" -> "已入账" + | "pending_nav" -> "待净值" + | other -> other + +let private dividendPanel model dispatch = + Html.section [ + prop.className "panel dividend-panel" + prop.children [ + Html.div [ + prop.className "section-heading" + prop.children [ + Html.div [ + Html.p [ prop.className "eyebrow"; prop.text "09 / DIVIDEND" ] + Html.h2 "分红记录" + ] + Html.span [ prop.className "section-note"; prop.text "Dividends - booked history" ] + ] + ] + Html.div [ + prop.className "dividend-history" + prop.children [ + if List.isEmpty model.dividends then + Html.p [ prop.className "hint"; prop.text "暂无分红记录" ] + else + yield! + (model.dividends + |> List.map (fun record -> + Html.div [ + prop.className "order-row dividend-row" + prop.children [ + Html.span [ prop.className "order-code"; prop.text record.instrumentCode ] + Html.span [ + prop.className "order-cell" + prop.text (sprintf "持仓日期 %s" record.navDate) + ] + Html.span [ + prop.className "order-cell" + prop.text (sprintf "类型 %s" (dividendModeText record.mode)) + ] + Html.span [ + prop.className "order-cell" + prop.text (sprintf "每份分红 %s" record.dps) + ] + match record.grossCash with + | Some cash -> + Html.span [ + prop.className "order-cell" + prop.text (sprintf "入账现金 %s" cash) + ] + | None -> Html.none + match record.creditedUnits with + | Some units -> + Html.span [ + prop.className "order-cell" + prop.text (sprintf "入账份额 %s" units) + ] + | None -> Html.none + Html.span [ prop.className "order-status"; prop.text (dividendStatusText record.status) ] + Html.span [ + prop.className "order-cell dividend-key" + prop.title record.sourceKey + prop.text record.sourceKey + ] + ] + ])) + ] + ] + Html.div [ + prop.className "panel-actions" + prop.children [ + Html.button [ + prop.className "secondary-action dividend-refresh-action" + prop.disabled model.dividendInFlight + prop.onClick (fun _ -> dispatch DividendsReadRequested) + prop.text ((if model.dividendInFlight then "读取中..." else "刷新分红记录"): string) + ] + ] + ] + ] + ] + let view model dispatch = Html.main [ prop.className "app-shell" @@ -2911,6 +3110,7 @@ let view model dispatch = redeemPanel model dispatch sipPanel model dispatch rebalancePanel model dispatch + dividendPanel model dispatch Html.footer [ prop.className "footer-note"; prop.text "SOURCE · AKShare / STORAGE · PostgreSQL / LEDGER · CREATE & READ & SUBSCRIBE" ] ] ] diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index d4762ee..f3821f9 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -119,6 +119,10 @@ export function confirmRedemption(token, fundId, orderId, idempotencyKey) { ); } +export function getDividends(token, fundId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/dividends`, token); +} + export function createSipPlan(token, fundId, payload) { const body = `{"instrumentCode":${JSON.stringify(payload.instrumentCode)},"amount":${JSON.stringify(payload.amount)},"frequency":${JSON.stringify(payload.frequency)}}`; return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token, { diff --git a/src/FundLab.Web/src/styles.css b/src/FundLab.Web/src/styles.css index bd072a5..d3f0e33 100644 --- a/src/FundLab.Web/src/styles.css +++ b/src/FundLab.Web/src/styles.css @@ -522,6 +522,20 @@ h2 { font-size: 11px; } +.dividend-history { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dividend-key { + font-family: "SFMono-Regular", Consolas, monospace; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .position-row { background: #f8fafc; } |
