diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-22 11:08:05 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-22 11:08:05 +0800 |
| commit | 9818daebaf6fb99124a3196f880b3b8a7998d3a6 (patch) | |
| tree | 0b027703ff1ff164a89965723cfd9d5a3fee5081 /src/FundLab.Web/App.fs | |
| parent | 8a68f4a98f51ff05b00bb7c14da7bca2a8c6eb98 (diff) | |
| download | fund-lab-9818daebaf6fb99124a3196f880b3b8a7998d3a6.tar.gz | |
Add cash ledger event-source breakdown and reconciliation API/UI (3d-31 B4c)
Diffstat (limited to 'src/FundLab.Web/App.fs')
| -rw-r--r-- | src/FundLab.Web/App.fs | 240 |
1 files changed, 238 insertions, 2 deletions
diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 56422ed..232608f 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -568,6 +568,35 @@ type RawFundValuation = positions: RawValuationPosition array } +type RawCashLedgerEvent = + { + source: string + eventDate: string + amount: string + referenceType: string + referenceId: string + note: obj + } + +type RawCashLedgerSource = + { + source: string + netAmount: string + eventCount: int + } + +type RawCashReconciliation = + { + fundId: string + openingCash: string + netInflow: string + closingCash: string + ledgerBalance: string + difference: string + sources: RawCashLedgerSource array + events: RawCashLedgerEvent array + } + type RawMarketRefreshTarget = { instrumentCode: string @@ -1024,6 +1053,34 @@ type FundValuationView = positions: ValuationPositionView list } +type CashLedgerEventView = + { + source: string + eventDate: string + amount: string + referenceType: string + referenceId: string + note: string option + } + +type CashLedgerSourceView = + { + source: string + netAmount: string + eventCount: int + } + +type CashReconciliationView = + { + openingCash: string + netInflow: string + closingCash: string + ledgerBalance: string + difference: string + sources: CashLedgerSourceView list + events: CashLedgerEventView list + } + type BondTradeAttempt = { idempotencyKey: string @@ -1113,6 +1170,9 @@ module Api = [<Import("getFundValuation", "./src/api.js")>] let getFundValuation (token: string) (fundId: string) : JS.Promise<RawFundValuation> = jsNative + [<Import("getCashReconciliation", "./src/api.js")>] + let getCashReconciliation (token: string) (fundId: string) : JS.Promise<RawCashReconciliation> = jsNative + [<Import("refreshFundMarketData", "./src/api.js")>] let refreshFundMarketData (token: string) (fundId: string) : JS.Promise<RawMarketRefresh> = jsNative @@ -1495,6 +1555,34 @@ module Api = positions = raw.positions |> Array.toList |> List.map decodeValuationPosition } + let decodeCashLedgerEvent (raw: RawCashLedgerEvent) : CashLedgerEventView = + { + source = raw.source + eventDate = raw.eventDate + amount = raw.amount + referenceType = raw.referenceType + referenceId = raw.referenceId + note = Option.ofObj raw.note |> Option.map string + } + + let decodeCashLedgerSource (raw: RawCashLedgerSource) : CashLedgerSourceView = + { + source = raw.source + netAmount = raw.netAmount + eventCount = raw.eventCount + } + + let decodeCashReconciliation (raw: RawCashReconciliation) : CashReconciliationView = + { + openingCash = raw.openingCash + netInflow = raw.netInflow + closingCash = raw.closingCash + ledgerBalance = raw.ledgerBalance + difference = raw.difference + sources = raw.sources |> Array.toList |> List.map decodeCashLedgerSource + events = raw.events |> Array.toList |> List.map decodeCashLedgerEvent + } + type Model = { token: string @@ -1633,6 +1721,11 @@ type Model = valuationInFlight: bool valuation: FundValuationView option valuationMessage: string option + cashLedgerReadSeq: int + cashLedgerInFlight: bool + cashReconciliation: CashReconciliationView option + cashLedgerMessage: string option + cashLedgerFilter: string marketRefreshSeq: int marketRefreshInFlight: bool marketRefreshMessage: string option @@ -1782,6 +1875,10 @@ type Msg = | ValuationReadRequested | ValuationReadCompleted of requestId: int * fundId: string * valuation: RawFundValuation | ValuationReadFailed of requestId: int * fundId: string * message: string + | CashLedgerReadRequested + | CashLedgerReadCompleted of requestId: int * fundId: string * reconciliation: RawCashReconciliation + | CashLedgerReadFailed of requestId: int * fundId: string * message: string + | CashLedgerFilterChanged of string | MarketRefreshRequested | MarketRefreshCompleted of requestId: int * fundId: string * response: RawMarketRefresh | MarketRefreshFailed of requestId: int * fundId: string * message: string @@ -1968,6 +2065,11 @@ let init () = valuationInFlight = false valuation = None valuationMessage = None + cashLedgerReadSeq = 0 + cashLedgerInFlight = false + cashReconciliation = None + cashLedgerMessage = None + cashLedgerFilter = "" marketRefreshSeq = 0 marketRefreshInFlight = false marketRefreshMessage = None @@ -2260,6 +2362,13 @@ let private refreshMarketDataCommand token fundId requestId = (fun response -> MarketRefreshCompleted(requestId, fundId, response)) (fun error -> MarketRefreshFailed(requestId, fundId, errorText error)) +let private readCashLedgerCommand token fundId requestId = + Cmd.OfPromise.either + (fun () -> Api.getCashReconciliation token fundId) + () + (fun reconciliation -> CashLedgerReadCompleted(requestId, fundId, reconciliation)) + (fun error -> CashLedgerReadFailed(requestId, fundId, errorText error)) + let private readInvestmentPlansCommand token fundId requestId = Cmd.OfPromise.either (fun () -> Api.getInvestmentPlans token fundId) @@ -2982,7 +3091,7 @@ let update message model = capitalNote = Some(sprintf "追加成功 + %s(现金余额已刷新)" amount) error = None }, - Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg ReturnsReadRequested ] + Cmd.batch [ Cmd.ofMsg FundReadRequested; Cmd.ofMsg ReturnsReadRequested; Cmd.ofMsg CashLedgerReadRequested ] else model, Cmd.none | CapitalDepositFailed (requestId, message) -> @@ -3822,7 +3931,7 @@ let update message model = stockSellQuantities = Map.remove code model.stockSellQuantities stockSellAttempts = Map.remove code model.stockSellAttempts }, - Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested ] + Cmd.batch [ Cmd.ofMsg StockPositionsReadRequested; Cmd.ofMsg ValuationReadRequested; Cmd.ofMsg CashLedgerReadRequested ] else model, Cmd.none | StockSellFailed (requestId, fundId, code, message) -> @@ -4056,6 +4165,44 @@ let update message model = Cmd.none else model, Cmd.none + | CashLedgerReadRequested -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) -> + let requestId = model.cashLedgerReadSeq + 1 + + { + model with + cashLedgerReadSeq = requestId + cashLedgerInFlight = true + cashLedgerMessage = None + }, + readCashLedgerCommand model.token fund.id requestId + | _ -> model, Cmd.none + | CashLedgerReadCompleted (requestId, fundId, reconciliation) -> + if requestId = model.cashLedgerReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + cashReconciliation = Some(Api.decodeCashReconciliation reconciliation) + cashLedgerInFlight = false + cashLedgerMessage = None + }, + Cmd.none + else + model, Cmd.none + | CashLedgerReadFailed (requestId, fundId, message) -> + if requestId = model.cashLedgerReadSeq + && (match model.createdFund with Some fund -> fund.id = fundId | None -> false) then + { + model with + cashReconciliation = None + cashLedgerInFlight = false + cashLedgerMessage = Some(sprintf "现金账不可用 — %s" message) + }, + Cmd.none + else + model, Cmd.none + | CashLedgerFilterChanged value -> { model with cashLedgerFilter = value }, Cmd.none | MarketRefreshRequested -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) -> @@ -6246,6 +6393,95 @@ let private stocksPanel model dispatch = ] ] ] + Html.div [ + prop.className "fund-cash-ledger" + prop.children [ + Html.p [ prop.className "returns-subheading"; prop.text "现金账对账" ] + Html.div [ + prop.className "search-row" + prop.children [ + Html.button [ + prop.className "secondary-action cash-ledger-refresh-action" + prop.disabled model.cashLedgerInFlight + prop.onClick (fun _ -> dispatch CashLedgerReadRequested) + prop.text ((if model.cashLedgerInFlight then "加载中..." else "刷新现金账"): string) + ] + Html.select [ + prop.className "cash-ledger-filter" + prop.value model.cashLedgerFilter + prop.onChange (fun (value: string) -> dispatch (CashLedgerFilterChanged value)) + prop.children ( + [ Html.option [ prop.value ""; prop.text "全部来源" ] ] + @ (match model.cashReconciliation with + | Some reconciliation -> + reconciliation.sources + |> List.map (fun source -> Html.option [ prop.value source.source; prop.text source.source ]) + | None -> []) + ) + ] + ] + ] + match model.cashReconciliation with + | Some reconciliation -> + let filteredEvents = + if model.cashLedgerFilter = "" then + reconciliation.events + else + reconciliation.events |> List.filter (fun event -> event.source = model.cashLedgerFilter) + + Html.div [ + prop.className "cash-ledger-content" + prop.children ( + [ Html.div [ + prop.className "metric-strip cash-ledger-metrics" + prop.children [ + bondQuoteMetric "期初现金" (Some reconciliation.openingCash) + bondQuoteMetric "净流入" (Some reconciliation.netInflow) + bondQuoteMetric "期末现金" (Some reconciliation.closingCash) + bondQuoteMetric "账实差异" (Some reconciliation.difference) + ] + ] + Html.div [ + prop.className "cash-ledger-sources" + prop.children ( + reconciliation.sources + |> List.map (fun source -> + Html.div [ + prop.className "cash-ledger-source-row" + prop.children [ + Html.span [ prop.className "fund-detail-label"; prop.text source.source ] + Html.span [ prop.className "fund-detail-value"; prop.text source.netAmount ] + Html.span [ prop.className "fund-detail-value"; prop.text (sprintf "%d 笔" source.eventCount) ] + ] + ]) + ) + ] + Html.div [ + prop.className "cash-ledger-events" + prop.children ( + filteredEvents + |> List.map (fun event -> + Html.div [ + prop.className "cash-ledger-event-row" + prop.children [ + Html.span [ prop.className "fund-detail-value"; prop.text event.eventDate ] + Html.span [ prop.className "fund-detail-label"; prop.text event.source ] + Html.span [ prop.className "fund-detail-value"; prop.text event.amount ] + Html.span [ prop.className "fund-detail-value"; prop.text (event.note |> Option.defaultValue "—") ] + ] + ]) + ) + ] ] + ) + ] + | None -> Html.p [ prop.className "hint"; prop.text "刷新后显示按来源分组的现金流水与账实对账;期末现金 = 期初 + 净流入,差异应为 0。" ] + Html.p [ + prop.className "cash-ledger-unavailable" + prop.hidden (model.cashLedgerMessage.IsNone) + prop.text (model.cashLedgerMessage |> Option.defaultValue "") + ] + ] + ] ] ] |
