XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

STHApplication

【MAUI】寄当家——安卓端交易管理系统

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Globalization;
using System.Text.Json;
using Microsoft.Data.Sqlite;

namespace JiDangJia.Core;

public sealed record FundingPreview(DbRow Account,DateTime Start,DateTime End,int Days,decimal Amount,bool HasTrash);

public static class FundingCost
{
    public static readonly Dictionary<string,string> Providers=new(){["BANK"]="银行信贷",["PERSON"]="个人",["OTHER"]="其他"};
    public static readonly Dictionary<string,string> RatePeriods=new(){["ANNUAL"]="年利率",["MONTHLY"]="月利率"};
    public static Dictionary<string,string> Bases(string period)=>period=="MONTHLY"
        ?new(){["30"]="每月按 30 天"}:new(){["365"]="每年按 365 天",["360"]="每年按 360 天"};
    public static DateTime Date(string value,string label)
    {
        if(!DateTime.TryParseExact(value,"yyyy-MM-dd",CultureInfo.InvariantCulture,DateTimeStyles.None,out var date))throw new ArgumentException(label+"格式不正确");
        return date;
    }
    public static void Validate(DbRow account)
    {
        if(string.IsNullOrWhiteSpace(account.Text("name"))||account.Text("name").Length>200)throw new ArgumentException("请填写资方名称,最多 200 字");
        if(!Providers.ContainsKey(account.Text("providerType")))throw new ArgumentException("请选择资方类型");
        BusinessService.Positive(account.Money("principal"),"资方本金");
        if(account.Money("principal")!=Fees.Round(account.Money("principal")))throw new ArgumentException("本金最多保留两位小数");
        if(!decimal.TryParse(account.Text("rate"),NumberStyles.Number,CultureInfo.InvariantCulture,out var rate)||rate<0||rate>100||decimal.Round(rate,6)!=rate)throw new ArgumentException("利率应为 0% 至 100%,最多六位小数");
        if(!RatePeriods.ContainsKey(account.Text("ratePeriod"))||!Bases(account.Text("ratePeriod")).ContainsKey(account.Text("dayBasis")))throw new ArgumentException("请选择有效的利率周期与计息口径");
        if(Date(account.Text("startDate"),"起息日期")>DateTime.Today)throw new ArgumentException("起息日期不能晚于今天");
    }
    public static string Rule(DbRow account)=>$"{RatePeriods.GetValueOrDefault(account.Text("ratePeriod"),"利率")} {account.Money("rate"):0.######}% · {Bases(account.Text("ratePeriod")).GetValueOrDefault(account.Text("dayBasis"),"")}";
    public static decimal Interest(DbRow account,DateTime start,DateTime end)
    {
        Validate(account);var origin=Date(account.Text("startDate"),"起息日期");start=start.Date;end=end.Date;
        if(start<origin||end<start)throw new ArgumentException("计息区间不能早于起息日,结束日不能早于开始日");
        decimal daily=account.Money("principal")*account.Money("rate")/100/account.Money("dayBasis");
        // Round cumulative interest at both boundaries so split settlements conserve cents.
        return Fees.Round(daily*(end-origin).Days)-Fees.Round(daily*(start-origin).Days);
    }
    internal static FundingPreview Preview(SqliteConnection c,string id,DateTime end)
    {
        var account=Database.Query(c,"SELECT * FROM FundingAccount WHERE id=@p0 AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("资方资金不存在或已移入回收站");
        Validate(account);var rows=Database.Query(c,"SELECT * FROM FundingSettlement WHERE accountId=@p0 ORDER BY periodEnd",id);
        var start=rows.Count>0?Date(rows[^1].Text("periodEnd"),"已结算日期"):Date(account.Text("startDate"),"起息日期");
        end=end.Date;
        if(account.Text("endDate").Length>0&&end>Date(account.Text("endDate"),"停止计息日期"))end=Date(account.Text("endDate"),"停止计息日期");
        if(end>DateTime.Today)throw new ArgumentException("计息截止日不能晚于今天");
        var amount=Interest(account,start,end);
        return new(account,start,end,(end-start).Days,amount,rows.Any(r=>r.Text("deletedAt").Length>0));
    }
    internal static DbRow? LinkedSettlement(SqliteConnection c,string table,string id)=>table=="FundingSettlement"
        ?Database.Query(c,"SELECT * FROM FundingSettlement WHERE id=@p0",id).FirstOrDefault()
        :table=="FinanceRecord"?Database.Query(c,"SELECT s.* FROM FundingSettlement s JOIN FinanceRecord f ON f.ticketId=s.id WHERE f.id=@p0",id).FirstOrDefault():null;
    internal static void ValidateDeletion(SqliteConnection c,string table,string id)
    {
        if(LinkedSettlement(c,table,id) is not {} settlement)return;
        if(Database.Query(c,"SELECT id FROM FundingSettlement WHERE accountId=@p0 AND periodEnd>@p1",settlement.Text("accountId"),settlement.Text("periodEnd")).Count>0)throw new InvalidOperationException("请从最新一笔资金成本结算开始删除,保持计息区间连续");
    }
    internal static void ValidateRestored(SqliteConnection c,HashSet<(string Table,string Id)> restored)
    {
        foreach(var settlement in Database.Query(c,"SELECT * FROM FundingSettlement WHERE deletedAt IS NULL"))
        {
            if(!restored.Contains(("FundingSettlement",settlement.Id)))continue;
            if(Database.Query(c,"SELECT id FROM FundingAccount WHERE id=@p0 AND deletedAt IS NULL",settlement.Text("accountId")).Count==0)throw new InvalidOperationException("请先还原资方资金记录");
            if(Database.Query(c,"SELECT id FROM FundingSettlement WHERE accountId=@p0 AND id<>@p1 AND periodStart<@p2 AND periodEnd>@p3 AND deletedAt IS NULL",settlement.Text("accountId"),settlement.Id,settlement.Text("periodEnd"),settlement.Text("periodStart")).Count>0)throw new InvalidOperationException("存在重叠的资金成本结算区间,无法还原");
        }
    }
}

public sealed partial class BusinessService
{
    public string SaveFunding(DbRow input)
    {
        var row=new DbRow(input);FundingCost.Validate(row);
        return db.Write(c=>
        {
            if(row.Id.Length>0)
            {
                var old=Database.Query(c,"SELECT * FROM FundingAccount WHERE id=@p0 AND deletedAt IS NULL",row.Id).FirstOrDefault()??throw new InvalidOperationException("资方资金记录不存在");
                if(old.Text("endDate").Length>0)throw new InvalidOperationException("资金已停止计息,不能修改;请新建资金记录");
                if(Database.Query(c,"SELECT id FROM FundingSettlement WHERE accountId=@p0",row.Id).Count>0&&(new[]{"principal","rate","dayBasis"}.Any(k=>old.Money(k)!=row.Money(k))||new[]{"ratePeriod","startDate"}.Any(k=>old.Text(k)!=row.Text(k))))throw new InvalidOperationException("已有结算,不能修改本金、利率或起息日;请先结清本笔,再新建资金记录");
                row["createdAt"]=old["createdAt"];
            }
            else{row["id"]=Database.Id("fund");row["createdAt"]=Database.Now;}
            // Only editable fields enter this table; callers cannot set closing/trash metadata.
            var safe=new DbRow{["id"]=row.Id,["name"]=row.Text("name").Trim(),["providerType"]=row.Text("providerType"),["principal"]=row.Money("principal"),["rate"]=row.Money("rate"),["ratePeriod"]=row.Text("ratePeriod"),["dayBasis"]=row.Int("dayBasis"),["startDate"]=row.Text("startDate"),["remark"]=row.Text("remark"),["updatedAt"]=Database.Now};
            if(row.ContainsKey("createdAt"))safe["createdAt"]=row["createdAt"];
            Database.Upsert(c,"FundingAccount",safe);Database.Log(c,"FUNDING_SAVE","FundingAccount",row.Id,safe.Text("name"));return row.Id;
        });
    }
    public FundingPreview FundingPreview(string id,DateTime end)=>db.Read(c=>FundingCost.Preview(c,id,end));
    public List<DbRow> FundingAccounts()=>db.Query("SELECT * FROM FundingAccount WHERE deletedAt IS NULL ORDER BY (endDate IS NOT NULL AND endDate<>'') ASC,createdAt DESC");
    public string SettleFunding(string id,DateTime expectedStart,DateTime end,DateTime paidDate,string remark="")
        =>db.Write(c=>SettleFunding(c,id,expectedStart,end,paidDate,remark));
    private string SettleFunding(SqliteConnection c,string id,DateTime expectedStart,DateTime end,DateTime paidDate,string remark)
    {
            var preview=FundingCost.Preview(c,id,end);
            if(preview.HasTrash)throw new InvalidOperationException("有资金结算在回收站,请先还原或永久删除后再结算");
            if(preview.Start!=expectedStart.Date)throw new InvalidOperationException("结算区间已变化,请刷新后重试,避免重复支出");
            if(preview.Days<=0)throw new ArgumentException("请选择晚于已结算截止日的日期");
            paidDate=paidDate.Date;if(paidDate<preview.End||paidDate>DateTime.Today)throw new ArgumentException("支出发生日期应在计息截止日至今天之间");
            var settlementId=Database.Id("fundsettle");
            var settlement=new DbRow{["id"]=settlementId,["accountId"]=id,["name"]=preview.Account.Text("name")+" · 资金成本",["periodStart"]=Display.Day(preview.Start),["periodEnd"]=Display.Day(preview.End),["days"]=preview.Days,["amount"]=preview.Amount,["occurDate"]=Display.Day(paidDate),["principal"]=preview.Account.Money("principal"),["rate"]=preview.Account.Money("rate"),["ratePeriod"]=preview.Account.Text("ratePeriod"),["dayBasis"]=preview.Account.Int("dayBasis"),["remark"]=remark,["createdAt"]=Database.Now};
            Database.Upsert(c,"FundingSettlement",settlement);
            if(preview.Amount>0)
            {
                var finance=new DbRow{["id"]=Database.Id("finance"),["type"]="EXPENSE",["category"]="FUNDING_COST",["majorClass"]="STORE",["detailClass"]="FUNDING_COST",["amount"]=preview.Amount,["ticketId"]=settlementId,["operatorId"]=Owner.Id,["occurDate"]=Display.Day(paidDate),["remark"]=$"{preview.Account.Text("name")}({FundingCost.Providers[preview.Account.Text("providerType")] }) · {Display.Day(preview.Start)} 至 {Display.Day(preview.End)}(不含) · {preview.Days} 天 · {FundingCost.Rule(preview.Account)}"+(remark.Length>0?" · "+remark:""),["createdAt"]=Database.Now};
                finance["allocations"]=JsonSerializer.Serialize(new[]{new DbRow{["major"]="STORE",["detail"]="FUNDING_COST",["amount"]=preview.Amount,["referenceTable"]="FundingSettlement",["referenceId"]=settlementId}});
                Database.Upsert(c,"FinanceRecord",finance);
            }
            Database.Log(c,"FUNDING_SETTLE","FundingSettlement",settlementId,settlement.Text("name"),$"{preview.Days} 天 / {preview.Amount:F2} 元");return settlementId;
    }
    public void EndFunding(string id,DateTime end)
        =>db.Write(c=>{EndFunding(c,id,end);return 0;});
    private static void EndFunding(SqliteConnection c,string id,DateTime end)
    {
            var p=FundingCost.Preview(c,id,end);
            if(p.Account.Text("endDate").Length>0)throw new InvalidOperationException("这笔资金已停止计息");
            if(p.HasTrash)throw new InvalidOperationException("请先处理回收站中的资金结算");
            if(p.Days!=0)throw new InvalidOperationException("请先将利息结算到停止计息日期");
            Database.Execute(c,"UPDATE FundingAccount SET endDate=@p0,updatedAt=@p1 WHERE id=@p2",Display.Day(p.End),Database.Now,id);Database.Log(c,"FUNDING_END","FundingAccount",id,p.Account.Text("name"));
    }
    public void CompleteFunding(string id,DateTime expectedStart,DateTime end,DateTime paidDate,string remark,bool finish,decimal? expectedAmount=null)
    {
        db.Write(c=>
        {
            var p=FundingCost.Preview(c,id,end);
            if(p.Start!=expectedStart.Date)throw new InvalidOperationException("结算区间已变化,请刷新后重试,避免重复支出");
            if(expectedAmount.HasValue&&p.Amount!=expectedAmount.Value)throw new InvalidOperationException("资金条件或费用已变化,请刷新预览后再确认");
            if(p.Days>0||!finish)SettleFunding(c,id,expectedStart,end,paidDate,remark);
            if(finish)EndFunding(c,id,end);
            return 0;
        });
    }
}