From 6abd4c206fbfe8369b3fde4c905619d08f13c5d1 Mon Sep 17 00:00:00 2001 From: "Somhairle H. Marisol" Date: Tue, 22 Sep 2026 05:23:52 +0800 Subject: Add SIP plan pause/resume and rebalance plan archive lifecycle (3d-24) --- qa/driver/browser-test.js | 186 ++++++++++++++++++++++- src/FundLab.Api/App.fs | 55 ++++++- src/FundLab.Api/Persistence.fs | 105 +++++++++++++ src/FundLab.Web/App.fs | 146 +++++++++++++++++- src/FundLab.Web/src/api.js | 20 +++ tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj | 2 + tests/FundLab.Api.Tests/RebalanceArchiveTests.fs | 149 ++++++++++++++++++ tests/FundLab.Api.Tests/SipPlanStatusTests.fs | 177 +++++++++++++++++++++ tests/FundLab.Web.Tests/BoundaryTests.fs | 60 ++++++++ 9 files changed, 894 insertions(+), 6 deletions(-) create mode 100644 tests/FundLab.Api.Tests/RebalanceArchiveTests.fs create mode 100644 tests/FundLab.Api.Tests/SipPlanStatusTests.fs diff --git a/qa/driver/browser-test.js b/qa/driver/browser-test.js index 8ad9ab5..dd49263 100644 --- a/qa/driver/browser-test.js +++ b/qa/driver/browser-test.js @@ -42,6 +42,8 @@ let lastStockPositionsResponse = null; let lastBondTradeResponse = null; let lastBondPositionsResponse = null; let lastValuationResponse = null; +let lastSipStatusResponse = null; +let lastSipAdvanceResponse = null; function check(name, ok, detail) { results.push({ name, ok, detail: detail || "" }); @@ -231,7 +233,17 @@ async function summaryLabelExists(page, label, name) { lastValuationResponse = await r.json(); } catch {} } - if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url())) { + if (/\/sip\/plans\/[0-9a-f-]+\/(pause|resume)$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) { + try { + lastSipStatusResponse = await r.json(); + } catch {} + } + if (/\/sip\/advance$/i.test(new URL(r.url()).pathname) && r.request().method() === "POST" && r.status() < 400) { + try { + lastSipAdvanceResponse = await r.json(); + } catch {} + } + if (r.status() >= 400 && !/\/api\/instruments\//.test(r.url()) && !/\/api\/market\//.test(r.url()) && !/\/orders/.test(r.url()) && !/\/redemptions/.test(r.url()) && !/\/capital/.test(r.url()) && !/\/stock-trades/.test(r.url()) && !/\/stock-positions/.test(r.url()) && !/\/bond-trades/.test(r.url()) && !/\/bond-positions/.test(r.url()) && !/\/valuation/.test(r.url()) && !/\/rebalance\/plans\//.test(r.url())) { consoleErrors.push("resource " + r.status() + ": " + r.url()); } }); @@ -288,6 +300,8 @@ async function summaryLabelExists(page, label, name) { await stockTradeScenario(page); await bondTradeScenario(page); await valuationScenario(page); + await sipStatusScenario(page); + await rebalanceArchiveScenario(page); } finally { check("G1 无浏览器控制台/页面错误", consoleErrors.length === 0, consoleErrors.slice(0, 3).join(" | ")); await browser.close(); @@ -1220,6 +1234,111 @@ async function plansScenario(page) { await page.screenshot({ path: SHOTS + "/17-investment-plans.png" }); } +async function sipStatusScenario(page) { + // P8: 定投计划暂停/恢复最小闭环(暂停时不扣款,恢复后到期继续执行)。 + await page.waitForSelector(".sip-panel", { timeout: 10000 }); + const fundId = await currentFundId(page); + const token = process.env.QA_TOKEN || "qa-token"; + + const planId = await page.evaluate( + async ({ fundId, token }) => { + const response = await fetch(`/api/funds/${fundId}/sip/plans`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Idempotency-Key": "qasippauseresume", + }, + body: JSON.stringify({ instrumentCode: "000001", amount: "10.00", frequency: "weekly" }), + }); + if (response.status !== 201) return ""; + const body = await response.json(); + return body.id || ""; + }, + { fundId, token } + ); + + await page.click(".sip-plans-refresh-action"); + await page.waitForSelector(".sip-panel .sip-plans .order-row", { timeout: 15000 }); + + lastSipStatusResponse = null; + await page.click(".sip-pause-action"); + + for (let i = 0; i < 100 && lastSipStatusResponse === null; i++) { + await page.waitForTimeout(100); + } + + const pausedText = ((await page.textContent(".sip-plans")) || "").trim(); + + const pausedAdvance = await page.evaluate( + async ({ fundId, token }) => { + const response = await fetch(`/api/funds/${fundId}/sip/advance`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ endDate: "2026-10-31" }), + }); + const body = await response.json(); + return body; + }, + { fundId, token } + ); + + const pausedPlans = pausedAdvance && Array.isArray(pausedAdvance.plans) ? pausedAdvance.plans : []; + const pausedOutcomes = pausedPlans.reduce( + (acc, plan) => acc.concat(Array.isArray(plan.executions) ? plan.executions : []), + [] + ); + + check( + "P8a 暂停定投计划不参与到期扣款", + planId !== "" && + lastSipStatusResponse && + lastSipStatusResponse.status === "paused" && + pausedText.includes("已暂停") && + pausedOutcomes.length === 0, + `plan=${planId} status=${lastSipStatusResponse?.status} outcomes=${pausedOutcomes.length} text=${pausedText.slice(0, 160)}` + ); + + lastSipStatusResponse = null; + await page.click(".sip-resume-action"); + + for (let i = 0; i < 100 && lastSipStatusResponse === null; i++) { + await page.waitForTimeout(100); + } + + const resumedText = ((await page.textContent(".sip-plans")) || "").trim(); + + const resumedAdvance = await page.evaluate( + async ({ fundId, token }) => { + const response = await fetch(`/api/funds/${fundId}/sip/advance`, { + method: "POST", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ endDate: "2026-10-31" }), + }); + const body = await response.json(); + return body; + }, + { fundId, token } + ); + + const resumedPlans = resumedAdvance && Array.isArray(resumedAdvance.plans) ? resumedAdvance.plans : []; + const resumedOutcomes = resumedPlans.reduce( + (acc, plan) => acc.concat(Array.isArray(plan.executions) ? plan.executions : []), + [] + ); + + check( + "P8b 恢复定投计划后到期继续执行", + lastSipStatusResponse && + lastSipStatusResponse.status === "active" && + resumedText.includes("进行中") && + resumedOutcomes.length > 0, + `status=${lastSipStatusResponse?.status} outcomes=${resumedOutcomes.length} text=${resumedText.slice(0, 160)}` + ); + + await page.screenshot({ path: SHOTS + "/27-sip-status.png" }); +} + async function rebalanceScenario(page) { // R 系列: 再平衡最小闭环(接 P 场景:现金 5343.00,持仓 134.01949252 @1.1902) await page.waitForSelector(".rebalance-panel", { timeout: 10000 }); @@ -1334,6 +1453,71 @@ async function rebalanceScenario(page) { await page.screenshot({ path: SHOTS + "/18-rebalance.png" }); } +async function rebalanceArchiveScenario(page) { + // P9: 再平衡计划归档后不可预览/执行。 + await page.waitForSelector(".rebalance-panel", { timeout: 10000 }); + const fundId = await currentFundId(page); + const token = process.env.QA_TOKEN || "qa-token"; + + await page.waitForSelector(".rebalance-plans .rebalance-plan-row", { timeout: 15000 }); + + const activePreviewCount = await page.locator(".rebalance-preview-action").count(); + const activeArchiveCount = await page.locator(".rebalance-archive-action").count(); + + await page.click(".rebalance-archive-action"); + await page.waitForFunction( + () => { + const element = document.querySelector(".rebalance-plans"); + return element && element.textContent.includes("已归档"); + }, + { timeout: 10000 } + ); + + const archivedText = ((await page.textContent(".rebalance-plans")) || "").trim(); + const archivedPreviewCount = await page.locator(".rebalance-preview-action").count(); + + const planId = await page.evaluate( + async ({ fundId, token }) => { + const response = await fetch(`/api/funds/${fundId}/rebalance/plans`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const plans = await response.json(); + const archived = (Array.isArray(plans) ? plans : []).find((plan) => plan.status === "archived"); + return archived ? archived.id : ""; + }, + { fundId, token } + ); + + const previewStatus = await page.evaluate( + async ({ fundId, planId, token }) => { + const response = await fetch(`/api/funds/${fundId}/rebalance/plans/${planId}/preview`, { + headers: { Authorization: `Bearer ${token}` }, + }); + let body = null; + try { + body = await response.json(); + } catch {} + return { status: response.status, body }; + }, + { fundId, planId, token } + ); + + check( + "P9 归档后再平衡计划不可预览且按钮隐藏", + activePreviewCount > 0 && + activeArchiveCount > 0 && + archivedText.includes("已归档") && + archivedPreviewCount === 0 && + planId !== "" && + previewStatus.status === 409 && + previewStatus.body && + previewStatus.body.error === "PLAN_ARCHIVED", + `active=${activePreviewCount} archived=${archivedPreviewCount} status=${previewStatus.status} text=${archivedText.slice(0, 160)}` + ); + + await page.screenshot({ path: SHOTS + "/28-rebalance-archive.png" }); +} + const failed = results.filter((r) => !r.ok); console.log(`\n==== ${results.length - failed.length}/${results.length} passed ====`); process.exit(failed.length > 0 ? 1 : 0); diff --git a/src/FundLab.Api/App.fs b/src/FundLab.Api/App.fs index 138230f..5ff7fdd 100644 --- a/src/FundLab.Api/App.fs +++ b/src/FundLab.Api/App.fs @@ -1505,6 +1505,29 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "sip plan persistence failed" next ctx + let private setSipPlanStatus + (repository: FundRepository) + (targetStatus: string) + (fundIdText: string) + (planIdText: string) + : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_SIP_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + match repository.SetSipPlanStatus(fundId, planId, targetStatus) with + | SipPlanStatusResult.SipPlanStatusChanged plan -> json (sipPlanResponse plan) next ctx + | SipPlanStatusResult.SipPlanStatusNotFound -> + errorResponse 404 "PLAN_NOT_FOUND" "sip plan does not belong to this fund" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "sip plan status update failed" next ctx + let private createInvestmentPlan (repository: FundRepository) (fundIdText: string) : HttpHandler = fun next ctx -> task { @@ -1735,6 +1758,24 @@ module App = with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan persistence failed" next ctx + let private archiveRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = + fun next ctx -> + match Guid.TryParse fundIdText, Guid.TryParse planIdText with + | (false, _), _ + | _, (false, _) -> + errorResponse 400 "INVALID_REBALANCE_REQUEST" "fund id and plan id must be UUIDs" next ctx + | (true, fundId), (true, planId) -> + try + match repository.GetFund fundId with + | None -> errorResponse 404 "FUND_NOT_FOUND" "fund was not found" next ctx + | Some _ -> + match repository.ArchiveRebalancePlan(fundId, planId) with + | RebalanceArchiveResult.RebalancePlanArchived plan -> json (rebalancePlanResponse plan) next ctx + | RebalanceArchiveResult.RebalanceArchiveNotFound -> + errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx + with _ -> + errorResponse 500 "PERSISTENCE_ERROR" "rebalance plan archive failed" next ctx + let private executeRebalancePlan (repository: FundRepository) (fundIdText: string) (planIdText: string) : HttpHandler = fun next ctx -> match Guid.TryParse fundIdText, Guid.TryParse planIdText with @@ -1749,7 +1790,10 @@ module App = | Some _ -> match repository.ExecuteRebalancePlan planId with | Error message -> - errorResponse (if message.Contains("was not found") then 404 else 400) "INVALID_REBALANCE_REQUEST" message next ctx + if message.Contains("is archived") then + errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx + else + errorResponse (if message.Contains("was not found") then 404 else 400) "INVALID_REBALANCE_REQUEST" message next ctx | Ok result -> if result.PlanId <> Guid.Empty && repository.GetRebalancePlans fundId |> List.exists (fun plan -> plan.Id = result.PlanId) |> not then errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx @@ -1787,7 +1831,11 @@ module App = errorResponse 404 "PLAN_NOT_FOUND" "rebalance plan does not belong to this fund" next ctx else match repository.PreviewRebalancePlan planId with - | Error message -> errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx + | Error message -> + if message.Contains("is archived") then + errorResponse 409 "PLAN_ARCHIVED" "rebalance plan is archived" next ctx + else + errorResponse 400 "INVALID_REBALANCE_REQUEST" message next ctx | Ok preview -> json (rebalancePreviewResponse preview) next ctx with _ -> errorResponse 500 "PERSISTENCE_ERROR" "rebalance preview failed" next ctx @@ -2275,8 +2323,11 @@ module App = POST >=> routef "/funds/%s/sip/plans" (createSipPlan repository) GET >=> routef "/funds/%s/sip/plans" (getSipPlans repository) POST >=> routef "/funds/%s/sip/advance" (advanceSipPlans repository) + POST >=> routef "/funds/%s/sip/plans/%s/pause" (fun (fundId, planId) -> setSipPlanStatus repository "paused" fundId planId) + POST >=> routef "/funds/%s/sip/plans/%s/resume" (fun (fundId, planId) -> setSipPlanStatus repository "active" fundId planId) POST >=> routef "/funds/%s/rebalance/plans" (createRebalancePlan repository) GET >=> routef "/funds/%s/rebalance/plans" (getRebalancePlans repository) + POST >=> routef "/funds/%s/rebalance/plans/%s/archive" (fun (fundId, planId) -> archiveRebalancePlan repository fundId planId) POST >=> routef "/funds/%s/rebalance/plans/%s/execute" (fun (fundId, planId) -> executeRebalancePlan repository fundId planId) GET >=> routef "/funds/%s/rebalance/plans/%s/preview" (fun (fundId, planId) -> previewRebalancePlan repository fundId planId) GET >=> routef "/funds/%s/rebalance/executions" (getRebalanceExecutions repository) diff --git a/src/FundLab.Api/Persistence.fs b/src/FundLab.Api/Persistence.fs index 5000161..379c10d 100644 --- a/src/FundLab.Api/Persistence.fs +++ b/src/FundLab.Api/Persistence.fs @@ -389,6 +389,10 @@ type SipPlanWriteResult = | SipPlanFundNotFound | SipPlanInstrumentNotFound +type SipPlanStatusResult = + | SipPlanStatusChanged of SipPlanRecord + | SipPlanStatusNotFound + type SipExecutionOutcome = { TradeDate: DateOnly @@ -494,6 +498,10 @@ type RebalanceWriteResult = | RebalanceFundNotFound | RebalanceInstrumentNotFound +type RebalanceArchiveResult = + | RebalancePlanArchived of RebalancePlanRecord + | RebalanceArchiveNotFound + type RebalanceOrderOutcome = { InstrumentCode: string @@ -3347,6 +3355,54 @@ type FundRepository(connectionString: string) = records |> Seq.toList + member _.SetSipPlanStatus(fundId: Guid, planId: Guid, status: string) : SipPlanStatusResult = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "sip-plan-status:%O" planId)) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findSipPlan connection (Some transaction) planId with + | Some plan when plan.FundId = fundId -> + if plan.Status = status then + transaction.Commit() + SipPlanStatusResult.SipPlanStatusChanged plan + else + use updateCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE sip_plans SET status = @status WHERE id = @plan_id" + + addParameter updateCommand "status" NpgsqlDbType.Text (box status) |> ignore + addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + updateCommand.ExecuteNonQuery() |> ignore + + let updated = findSipPlan connection (Some transaction) planId + transaction.Commit() + + match updated with + | Some updatedPlan -> SipPlanStatusResult.SipPlanStatusChanged updatedPlan + | None -> SipPlanStatusResult.SipPlanStatusNotFound + | _ -> + transaction.Rollback() + SipPlanStatusResult.SipPlanStatusNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + member _.CreateInvestmentPlan(idempotencyKey: string, fundId: Guid, command: InvestmentPlanCommand, ?anchorOverride: DateOnly) : InvestmentPlanWriteResult = if String.IsNullOrWhiteSpace idempotencyKey then InvestmentPlanWriteResult.InvestmentPlanInvalid "idempotency key cannot be empty" @@ -3838,6 +3894,53 @@ type FundRepository(connectionString: string) = reader.Close() plans |> Seq.toList |> List.map (rebalancePlanWithTargets connection None) + member _.ArchiveRebalancePlan(fundId: Guid, planId: Guid) : RebalanceArchiveResult = + use connection = new NpgsqlConnection(connectionString) + connection.Open() + use transaction = connection.BeginTransaction(IsolationLevel.ReadCommitted) + + try + use lockCommand = + commandWithTransaction + connection + (Some transaction) + "SELECT pg_advisory_xact_lock(hashtext(@lock_key))" + + addParameter lockCommand "lock_key" NpgsqlDbType.Text (box (sprintf "rebalance-archive:%O" planId)) |> ignore + lockCommand.ExecuteNonQuery() |> ignore + + match findRebalancePlan connection (Some transaction) planId with + | Some plan when plan.FundId = fundId -> + if plan.Status = "archived" then + transaction.Commit() + RebalanceArchiveResult.RebalancePlanArchived plan + else + use updateCommand = + commandWithTransaction + connection + (Some transaction) + "UPDATE rebalance_plans SET status = 'archived' WHERE id = @plan_id" + + addParameter updateCommand "plan_id" NpgsqlDbType.Uuid (box planId) |> ignore + updateCommand.ExecuteNonQuery() |> ignore + + let updated = findRebalancePlan connection (Some transaction) planId + transaction.Commit() + + match updated with + | Some updatedPlan -> RebalanceArchiveResult.RebalancePlanArchived updatedPlan + | None -> RebalanceArchiveResult.RebalanceArchiveNotFound + | _ -> + transaction.Rollback() + RebalanceArchiveResult.RebalanceArchiveNotFound + with error -> + try + transaction.Rollback() + with _ -> + () + + raise error + member this.ExecuteRebalancePlan(planId: Guid) : Result = use connection = new NpgsqlConnection(connectionString) connection.Open() @@ -3868,6 +3971,7 @@ type FundRepository(connectionString: string) = match plan with | None -> Error "rebalance plan was not found" + | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived" | Some plan -> let fundId = plan.FundId let runDate = ConfirmationPolicy.tradeDateFor DateTimeOffset.UtcNow @@ -4033,6 +4137,7 @@ type FundRepository(connectionString: string) = match findRebalancePlan connection None planId with | None -> Error "rebalance plan was not found" + | Some plan when plan.Status = "archived" -> Error "rebalance plan is archived" | Some plan -> match this.GetFund plan.FundId with | None -> Error "fund was not found" diff --git a/src/FundLab.Web/App.fs b/src/FundLab.Web/App.fs index 62bb20e..7dea8bb 100644 --- a/src/FundLab.Web/App.fs +++ b/src/FundLab.Web/App.fs @@ -1005,6 +1005,9 @@ module Api = [] let getRebalancePlans (token: string) (fundId: string) : JS.Promise = jsNative + [] + let archiveRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative + [] let executeRebalancePlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative @@ -1020,6 +1023,12 @@ module Api = [] let getSipPlans (token: string) (fundId: string) : JS.Promise = jsNative + [] + let pauseSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative + + [] + let resumeSipPlan (token: string) (fundId: string) (planId: string) : JS.Promise = jsNative + [] let getInvestmentPlans (token: string) (fundId: string) : JS.Promise = jsNative @@ -1411,11 +1420,15 @@ type Model = sipCreateSeq: int sipReadSeq: int sipInFlight: bool + sipStatusSeq: int + sipStatusInFlight: bool lastSipAttempt: SipAttempt option sipPlans: SipPlan list rebalTargets: RebalanceTargetDraft list rebalCreateSeq: int rebalReadSeq: int + rebalArchiveSeq: int + rebalArchiveInFlight: bool rebalInFlight: bool rebalExecuteSeq: int rebalExecuteInFlight: bool @@ -1548,6 +1561,10 @@ type Msg = | SipPlansReadRequested | SipPlansReadCompleted of requestId: int * plans: RawSipPlan array | SipPlansReadFailed of requestId: int * message: string + | SipPlanPauseRequested of planId: string + | SipPlanResumeRequested of planId: string + | SipPlanStatusChanged of requestId: int * plan: RawSipPlan + | SipPlanStatusChangeFailed of requestId: int * message: string | RebalTargetCodeChanged of index: int * value: string | RebalTargetPercentChanged of index: int * value: string | RebalTargetAdded @@ -1558,6 +1575,9 @@ type Msg = | RebalancePlansReadRequested | RebalancePlansReadCompleted of requestId: int * plans: RawRebalancePlan array | RebalancePlansReadFailed of requestId: int * message: string + | RebalancePlanArchiveRequested of planId: string + | RebalancePlanArchived of requestId: int * plan: RawRebalancePlan + | RebalancePlanArchiveFailed of requestId: int * message: string | RebalancePreviewRequested of planId: string | RebalancePreviewCompleted of requestId: int * preview: RawRebalancePreview | RebalancePreviewFailed of requestId: int * message: string @@ -1705,11 +1725,15 @@ let init () = sipCreateSeq = 0 sipReadSeq = 0 sipInFlight = false + sipStatusSeq = 0 + sipStatusInFlight = false lastSipAttempt = None sipPlans = [] rebalTargets = [ { code = ""; percent = "" }; { code = ""; percent = "" } ] rebalCreateSeq = 0 rebalReadSeq = 0 + rebalArchiveSeq = 0 + rebalArchiveInFlight = false rebalInFlight = false rebalExecuteSeq = 0 rebalExecuteInFlight = false @@ -1894,6 +1918,20 @@ let private readSipPlansCommand token fundId requestId = (fun plans -> SipPlansReadCompleted(requestId, plans)) (fun error -> SipPlansReadFailed(requestId, errorText error)) +let private pauseSipPlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.pauseSipPlan token fundId planId) + () + (fun plan -> SipPlanStatusChanged(requestId, plan)) + (fun error -> SipPlanStatusChangeFailed(requestId, errorText error)) + +let private resumeSipPlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.resumeSipPlan token fundId planId) + () + (fun plan -> SipPlanStatusChanged(requestId, plan)) + (fun error -> SipPlanStatusChangeFailed(requestId, errorText error)) + let private createRebalancePlanCommand token fundId payload requestId = Cmd.OfPromise.either (fun () -> Api.createRebalancePlan token fundId payload) @@ -1908,6 +1946,13 @@ let private readRebalancePlansCommand token fundId requestId = (fun plans -> RebalancePlansReadCompleted(requestId, plans)) (fun error -> RebalancePlansReadFailed(requestId, errorText error)) +let private archiveRebalancePlanCommand token fundId planId requestId = + Cmd.OfPromise.either + (fun () -> Api.archiveRebalancePlan token fundId planId) + () + (fun plan -> RebalancePlanArchived(requestId, plan)) + (fun error -> RebalancePlanArchiveFailed(requestId, errorText error)) + let private executeRebalanceCommand token fundId planId requestId = Cmd.OfPromise.either (fun () -> Api.executeRebalancePlan token fundId planId) @@ -2027,6 +2072,18 @@ let private runInvestmentPlansCommand token fundId requestId = (fun run -> InvestmentPlansRunCompleted(requestId, fundId, run)) (fun error -> InvestmentPlansRunFailed(requestId, fundId, errorText error)) +let private dispatchSipStatusChange model planId pause = + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.sipStatusInFlight -> + let requestId = model.sipStatusSeq + 1 + + { model with sipStatusSeq = requestId; sipStatusInFlight = true; error = None }, + (if pause then + pauseSipPlanCommand model.token fund.id planId requestId + else + resumeSipPlanCommand model.token fund.id planId requestId) + | _ -> model, Cmd.none + let update message model = match message with | TokenChanged token -> @@ -2834,6 +2891,28 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | SipPlanPauseRequested planId -> dispatchSipStatusChange model planId true + | SipPlanResumeRequested planId -> dispatchSipStatusChange model planId false + | SipPlanStatusChanged (requestId, plan) -> + if requestId = model.sipStatusSeq then + let decoded = Api.decodeSipPlan plan + + { + model with + sipStatusInFlight = false + sipPlans = + model.sipPlans + |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) + error = None + }, + Cmd.none + else + model, Cmd.none + | SipPlanStatusChangeFailed (requestId, message) -> + if requestId = model.sipStatusSeq then + { model with sipStatusInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | RebalTargetCodeChanged (index, value) -> { model with @@ -2958,6 +3037,33 @@ let update message model = { model with error = Some message }, Cmd.none else model, Cmd.none + | RebalancePlanArchiveRequested planId -> + match model.createdFund with + | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalArchiveInFlight -> + let requestId = model.rebalArchiveSeq + 1 + { model with rebalArchiveSeq = requestId; rebalArchiveInFlight = true; error = None }, + archiveRebalancePlanCommand model.token fund.id planId requestId + | _ -> model, Cmd.none + | RebalancePlanArchived (requestId, plan) -> + if requestId = model.rebalArchiveSeq then + let decoded = Api.decodeRebalancePlan plan + + { + model with + rebalArchiveInFlight = false + rebalPlans = + model.rebalPlans + |> List.map (fun existing -> if existing.id = decoded.id then decoded else existing) + error = None + }, + Cmd.none + else + model, Cmd.none + | RebalancePlanArchiveFailed (requestId, message) -> + if requestId = model.rebalArchiveSeq then + { model with rebalArchiveInFlight = false; error = Some message }, Cmd.none + else + model, Cmd.none | RebalancePreviewRequested planId -> match model.createdFund with | Some fund when not (String.IsNullOrWhiteSpace model.token) && not model.rebalPreviewInFlight -> @@ -4394,7 +4500,7 @@ let private sipFrequencyText (frequency: string) = elif frequency = "monthly" then "每月" else frequency -let private sipPlanRow (plan: SipPlan) = +let private sipPlanRow dispatch (plan: SipPlan) = Html.div [ prop.className "order-row" prop.children [ @@ -4418,7 +4524,31 @@ let private sipPlanRow (plan: SipPlan) = | None -> "最近执行 暂无" Html.span [ prop.className "order-cell"; prop.text lastExecution ] - Html.span [ prop.className "order-status"; prop.text (if plan.status = "active" then "进行中" else plan.status) ] + Html.span [ + prop.className "order-status" + prop.text ( + if plan.status = "active" then "进行中" + elif plan.status = "paused" then "已暂停" + else plan.status + ) + ] + Html.span [ + prop.className "order-cell sip-plan-actions" + prop.children [ + if plan.status = "paused" then + Html.button [ + prop.className "secondary-action sip-resume-action" + prop.onClick (fun _ -> dispatch (SipPlanResumeRequested plan.id)) + prop.text "恢复" + ] + else + Html.button [ + prop.className "secondary-action sip-pause-action" + prop.onClick (fun _ -> dispatch (SipPlanPauseRequested plan.id)) + prop.text "暂停" + ] + ] + ] ] ] @@ -4497,7 +4627,7 @@ let private sipPanel model dispatch = if List.isEmpty model.sipPlans then Html.p [ prop.className "hint"; prop.text "暂无定投计划" ] else - yield! (model.sipPlans |> List.map sipPlanRow) + yield! (model.sipPlans |> List.map (sipPlanRow dispatch)) ] ] Html.div [ @@ -4691,6 +4821,10 @@ let private rebalancePanel model dispatch = |> List.map (fun target -> sprintf "%s %s%%" target.instrumentCode target.targetPercent) |> String.concat " / ") ] + Html.span [ + prop.className "order-status" + prop.text (if plan.status = "archived" then "已归档" else "进行中") + ] (if plan.status = "active" then Html.span [ prop.className "order-actions" @@ -4707,6 +4841,12 @@ let private rebalancePanel model dispatch = prop.onClick (fun _ -> dispatch (RebalanceExecuteRequested plan.id)) prop.text ((if model.rebalExecuteInFlight then "执行中..." else "执行再平衡"): string) ] + Html.button [ + prop.className "secondary-action rebalance-archive-action" + prop.disabled model.rebalArchiveInFlight + prop.onClick (fun _ -> dispatch (RebalancePlanArchiveRequested plan.id)) + prop.text "归档" + ] ] ] else diff --git a/src/FundLab.Web/src/api.js b/src/FundLab.Web/src/api.js index 031ff74..a03b97f 100644 --- a/src/FundLab.Web/src/api.js +++ b/src/FundLab.Web/src/api.js @@ -199,6 +199,18 @@ export function getSipPlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans`, token); } +export function pauseSipPlan(token, fundId, planId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans/${encodeURIComponent(planId)}/pause`, token, { + method: "POST" + }); +} + +export function resumeSipPlan(token, fundId, planId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/sip/plans/${encodeURIComponent(planId)}/resume`, token, { + method: "POST" + }); +} + export function getInvestmentPlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/investment-plans`, token); } @@ -227,6 +239,14 @@ export function getRebalancePlans(token, fundId) { return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans`, token); } +export function archiveRebalancePlan(token, fundId, planId) { + return requestJson(`/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/archive`, token, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}" + }); +} + export function executeRebalancePlan(token, fundId, planId) { return requestJson( `/api/funds/${encodeURIComponent(fundId)}/rebalance/plans/${encodeURIComponent(planId)}/execute`, diff --git a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj index 38a9e7e..d42612a 100644 --- a/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj +++ b/tests/FundLab.Api.Tests/FundLab.Api.Tests.fsproj @@ -37,6 +37,8 @@ + + diff --git a/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs b/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs new file mode 100644 index 0000000..ebb238b --- /dev/null +++ b/tests/FundLab.Api.Tests/RebalanceArchiveTests.fs @@ -0,0 +1,149 @@ +namespace FundLab.Api.Tests + +open System +open Xunit +open FundLab.Api + +[] +type RebalanceArchiveTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "再平衡归档测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "rebalance-archive-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "再平衡归档测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "rebalance-archive-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app () = App.createApplication (repository ()) + + let createPlan (fundId: Guid) (code: string) = + let key = fixture.Key(sprintf "rebalance-archive-plan-%s" (Guid.NewGuid().ToString("N"))) + + match + repository().CreateRebalancePlan(key, fundId, { Targets = [ { InstrumentCode = code; TargetPercent = 100.00m } ] }) + with + | RebalanceWriteResult.RebalancePlanCreated plan -> plan.Id + | other -> failwithf "unexpected rebalance plan result: %A" other + + let archiveViaApi fundId planId = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans/%O/archive" fundId planId) + [ "Authorization", "Bearer test-token" ] + "{}" + + let previewViaApi fundId planId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/rebalance/plans/%O/preview" fundId planId) + [ "Authorization", "Bearer test-token" ] + "" + + let executeViaApi fundId planId = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/rebalance/plans/%O/execute" fundId planId) + [ "Authorization", "Bearer test-token" ] + "{}" + + let plansViaApi fundId = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/rebalance/plans" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [] + member _.``archiving an active plan marks it archived in the plan list``() = + let fundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlan fundId code + + let status, body = archiveViaApi fundId planId + Assert.Equal(200, status) + Assert.Contains("\"status\":\"archived\"", body) + + let _, plansBody = plansViaApi fundId + Assert.Contains("\"status\":\"archived\"", plansBody) + + [] + member _.``archived plan cannot be previewed``() = + let fundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlan fundId code + + let _, _ = archiveViaApi fundId planId + let status, body = previewViaApi fundId planId + + Assert.Equal(409, status) + Assert.Contains("PLAN_ARCHIVED", body) + + [] + member _.``archived plan cannot be executed``() = + let fundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlan fundId code + + let _, _ = archiveViaApi fundId planId + let status, body = executeViaApi fundId planId + + Assert.Equal(409, status) + Assert.Contains("PLAN_ARCHIVED", body) + + [] + member _.``archiving an already archived plan is idempotent``() = + let fundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlan fundId code + + let firstStatus, firstBody = archiveViaApi fundId planId + Assert.Equal(200, firstStatus) + Assert.Contains("\"status\":\"archived\"", firstBody) + + let secondStatus, secondBody = archiveViaApi fundId planId + Assert.Equal(200, secondStatus) + Assert.Contains("\"status\":\"archived\"", secondBody) + + [] + member _.``archiving a plan owned by another fund answers 404``() = + let fundId = createFund 1000.00m + let otherFundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlan fundId code + + let status, body = archiveViaApi otherFundId planId + Assert.Equal(404, status) + Assert.Contains("PLAN_NOT_FOUND", body) diff --git a/tests/FundLab.Api.Tests/SipPlanStatusTests.fs b/tests/FundLab.Api.Tests/SipPlanStatusTests.fs new file mode 100644 index 0000000..780d85f --- /dev/null +++ b/tests/FundLab.Api.Tests/SipPlanStatusTests.fs @@ -0,0 +1,177 @@ +namespace FundLab.Api.Tests + +open System +open Xunit +open FundLab.Api +open FundLab.Domain + +[] +type SipPlanStatusTests(fixture: PostgresFixture) = + let sharedRepository = + lazy + let value = FundRepository(fixture.ConnectionString) + value.EnsureSchema() + value + + let repository () = sharedRepository.Value + + let seedInstrument () = + let code = Random.Shared.Next(0, 1000000).ToString("D6") + + let payload = + { + Source = "akshare" + SourceRevision = "akshare-test/eastmoney" + CollectedAt = DateTimeOffset(2026, 9, 21, 8, 0, 0, TimeSpan.Zero) + Instruments = [ { Code = code; Name = "定投状态测试基金"; FundType = None } ] + } + + repository().UpsertInstruments(payload, "sip-status-test-hash") + code + + let createFund (initialCash: decimal) = + let command = + { + Name = "定投状态测试 FOF" + InitialCash = initialCash + InitialUnitNav = 1.00000000m + IsSynthetic = true + } + + let key = fixture.Key(sprintf "sip-status-fund-%s" (Guid.NewGuid().ToString("N"))) + + match repository().CreateFund(key, command) with + | FundWriteResult.Created fund -> fund.Id + | other -> failwithf "unexpected fund creation result: %A" other + + let app () = App.createApplication (repository ()) + + let truncateMicroseconds (moment: DateTimeOffset) = + let utc = moment.ToUniversalTime() + DateTimeOffset(utc.Ticks - (utc.Ticks % 10L), TimeSpan.Zero) + + let insertQuoteOnDate (code: string) (nav: decimal) (navDate: DateOnly) = + let revision = sprintf "akshare-test/%O" (Guid.NewGuid()) + + let payload: MarketDataNavPayload = + { + Source = "akshare" + SourceRevision = revision + CollectedAt = truncateMicroseconds (DateTimeOffset.Now.AddSeconds(-10.0)) + Code = code + Observations = + [ + { + NavDate = navDate + PublishedAt = None + Nav = nav + AccumulatedNav = Some nav + DailyReturn = Some 0.0m + } + ] + } + + repository().UpsertNavObservations(payload, sprintf "sip-status-hash/%s" revision) + + let createPlanWithAnchor (fundId: Guid) (code: string) (amount: decimal) (frequency: SipFrequency) = + let key = fixture.Key(sprintf "sip-status-plan-%s" (Guid.NewGuid().ToString("N"))) + + match + repository().CreateSipPlan( + key, + fundId, + { InstrumentCode = code; Amount = amount; Frequency = frequency }, + DateOnly(2026, 9, 7) + ) + with + | SipPlanWriteResult.SipPlanCreated plan -> plan.Id + | other -> failwithf "unexpected sip plan result: %A" other + + let postStatus (fundId: Guid) (planId: Guid) (action: string) = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/sip/plans/%O/%s" fundId planId action) + [ "Authorization", "Bearer test-token" ] + "" + + let advance (fundId: Guid) (body: string) = + PersistenceTestHelpers.invoke + (app ()) + "POST" + (sprintf "/api/funds/%O/sip/advance" fundId) + [ "Authorization", "Bearer test-token" ] + body + + let listPlans (fundId: Guid) = + PersistenceTestHelpers.invoke + (app ()) + "GET" + (sprintf "/api/funds/%O/sip/plans" fundId) + [ "Authorization", "Bearer test-token" ] + "" + + [] + member _.``pause skips advance, resume lets the next due period execute again``() = + let fundId = createFund 10000.00m + let code = seedInstrument () + let planId = createPlanWithAnchor fundId code 100.00m Weekly + + insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 14)) + insertQuoteOnDate code 2.5m (DateOnly(2026, 9, 21)) + + let pauseStatus, pauseBody = postStatus fundId planId "pause" + Assert.Equal(200, pauseStatus) + Assert.Contains("\"status\":\"paused\"", pauseBody) + + let advanceStatus, advanceBody = advance fundId "{\"endDate\":\"2026-09-21\"}" + Assert.Equal(200, advanceStatus) + Assert.DoesNotContain("\"status\":\"succeeded\"", advanceBody) + Assert.DoesNotContain("\"status\":\"pending_nav\"", advanceBody) + + let resumeStatus, resumeBody = postStatus fundId planId "resume" + Assert.Equal(200, resumeStatus) + Assert.Contains("\"status\":\"active\"", resumeBody) + + let _, resumedBody = advance fundId "{\"endDate\":\"2026-09-21\"}" + Assert.Contains("\"status\":\"succeeded\"", resumedBody) + Assert.Contains("\"tradeDate\":\"2026-09-14\"", resumedBody) + + [] + member _.``pausing an already paused plan is idempotent``() = + let fundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlanWithAnchor fundId code 100.00m Weekly + + let firstStatus, firstBody = postStatus fundId planId "pause" + Assert.Equal(200, firstStatus) + Assert.Contains("\"status\":\"paused\"", firstBody) + + let secondStatus, secondBody = postStatus fundId planId "pause" + Assert.Equal(200, secondStatus) + Assert.Contains("\"status\":\"paused\"", secondBody) + + [] + member _.``paused plan status is reflected in the sip plan list``() = + let fundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlanWithAnchor fundId code 100.00m Weekly + + let _, _ = postStatus fundId planId "pause" + let _, listBody = listPlans fundId + Assert.Contains("\"status\":\"paused\"", listBody) + + let _, _ = postStatus fundId planId "resume" + let _, resumedListBody = listPlans fundId + Assert.Contains("\"status\":\"active\"", resumedListBody) + + [] + member _.``pausing a plan that belongs to another fund answers 404``() = + let fundId = createFund 1000.00m + let otherFundId = createFund 1000.00m + let code = seedInstrument () + let planId = createPlanWithAnchor fundId code 100.00m Weekly + + let status, body = postStatus otherFundId planId "pause" + Assert.Equal(404, status) + Assert.Contains("PLAN_NOT_FOUND", body) diff --git a/tests/FundLab.Web.Tests/BoundaryTests.fs b/tests/FundLab.Web.Tests/BoundaryTests.fs index d8bb472..4c05f37 100644 --- a/tests/FundLab.Web.Tests/BoundaryTests.fs +++ b/tests/FundLab.Web.Tests/BoundaryTests.fs @@ -1164,6 +1164,36 @@ module SipBoundaryTests = Assert.Equal(1, staleList.sipPlans.Length) + [] + let ``sip pause completion updates the matching plan row status`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFund ()) + sipStatusSeq = 1 + sipStatusInFlight = true + sipPlans = [ Api.decodeSipPlan (sampleSipRaw ()) ] } + + let pausedRaw = { sampleSipRaw () with status = "paused" } + let updated, _ = update (SipPlanStatusChanged(1, pausedRaw)) model + + Assert.Equal("paused", updated.sipPlans[0].status) + Assert.False(updated.sipStatusInFlight) + + [] + let ``sip pause failure clears in-flight and surfaces a message`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFund ()) + sipStatusSeq = 1 + sipStatusInFlight = true } + + let updated, _ = update (SipPlanStatusChangeFailed(1, "sip plan does not belong to this fund")) model + + Assert.Equal(Some "sip plan does not belong to this fund", updated.error) + Assert.False(updated.sipStatusInFlight) + module RebalanceBoundaryTests = open Xunit @@ -1249,3 +1279,33 @@ module RebalanceBoundaryTests = Assert.True(stale.rebalExecuteInFlight) Assert.Equal(None, stale.rebalResult) + + [] + let ``rebalance archive completion updates the matching plan row status`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFundInfo ()) + rebalArchiveSeq = 1 + rebalArchiveInFlight = true + rebalPlans = [ Api.decodeRebalancePlan (sampleRebalanceRaw ()) ] } + + let archivedRaw = { sampleRebalanceRaw () with status = "archived" } + let updated, _ = update (RebalancePlanArchived(1, archivedRaw)) model + + Assert.Equal("archived", updated.rebalPlans[0].status) + Assert.False(updated.rebalArchiveInFlight) + + [] + let ``rebalance archive failure clears in-flight and surfaces a message`` () = + let model = + { init () with + token = "qa-token" + createdFund = Some(sampleFundInfo ()) + rebalArchiveSeq = 1 + rebalArchiveInFlight = true } + + let updated, _ = update (RebalancePlanArchiveFailed(1, "rebalance plan does not belong to this fund")) model + + Assert.Equal(Some "rebalance plan does not belong to this fund", updated.error) + Assert.False(updated.rebalArchiveInFlight) -- cgit v1.2.3