summaryrefslogtreecommitdiff
path: root/src/FundLab.Web/App.fs
diff options
context:
space:
mode:
Diffstat (limited to 'src/FundLab.Web/App.fs')
-rw-r--r--src/FundLab.Web/App.fs240
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 "")
+ ]
+ ]
+ ]
]
]