using Microsoft.Data.Sqlite;
namespace JiDangJia.Core;
public sealed partial class BusinessService(Database db)
{
public Database Db => db;
public DbRow Shop => db.Query("SELECT * FROM Shop LIMIT 1").First();
public DbRow Owner => db.Query("SELECT * FROM User WHERE active=1 ORDER BY (role='店长') DESC,(id='user_owner') DESC LIMIT 1").FirstOrDefault() ?? new DbRow { ["name"]="店长" };
public string Category(string code)=>db.Query("SELECT label FROM FinanceCategory WHERE code=@p0 ORDER BY enabled DESC LIMIT 1",code).FirstOrDefault()?.Text("label")??Display.Category(code);
public string ItemType(string code)=>db.Query("SELECT label FROM PawnItemTypeRate WHERE itemType=@p0 ORDER BY enabled DESC LIMIT 1",code).FirstOrDefault()?.Text("label")??Display.Item(code);
public string Number(SqliteConnection c,string table,string field,string prefix)
{
var head=prefix+DateTime.Today.ToString("yyyyMMdd");
var last=Database.Query(c,$"SELECT [{field}] FROM [{table}] WHERE [{field}] LIKE @p0 ORDER BY [{field}] DESC LIMIT 1",head+"%").FirstOrDefault()?.Text(field);
int next=last is not null && int.TryParse(last[head.Length..],out var n)?n+1:1;
return head+next.ToString("D4");
}
public string Customer(DbRow row)
{
if (string.IsNullOrWhiteSpace(row.Text("name"))) throw new ArgumentException("请填写客户姓名");
if(row.Text("phone").Length>0 && (row.Text("phone").Length<6 || !row.Text("phone").All(c=>char.IsDigit(c)||c is '+' or '-' or ' '))) throw new ArgumentException("请填写正确的联系电话");
return db.Write(c=>
{
if(row.Id.Length==0) { row["id"]=Database.Id("customer"); row["code"]=Number(c,"Customer","code","C"); row["createdAt"]=Database.Now; }
row["updatedAt"]=Database.Now; Database.Upsert(c,"Customer",row); Database.Log(c,"CUSTOMER_SAVE","Customer",row.Id,row.Text("name")); return row.Id;
});
}
private DbRow RequireCustomer(SqliteConnection c,string id)
{
var customer=Database.Query(c,"SELECT * FROM Customer WHERE id=@p0 AND deletedAt IS NULL",id).FirstOrDefault() ?? throw new ArgumentException("请选择客户");
if(customer.Int("isBlacklist")==1) throw new ArgumentException("该客户已加入黑名单,请先核实客户信用");
return customer;
}
public static void Positive(decimal amount,string label) { if(amount<=0 || amount>999999999) throw new ArgumentException(label+"应大于零且不超过 999,999,999"); }
private static void ValidItems(IReadOnlyList<DbRow> items)
{
if(items.Count==0 || items.Any(i=>string.IsNullOrWhiteSpace(i.Text("name")))) throw new ArgumentException("请填写物品名称");
if(items.Any(i=>new[]{"weight","amount","appraisalPrice","unitPrice","quotedPrice"}.Any(k=>i.Money(k)<0 || i.Money(k)>999999999))) throw new ArgumentException("重量、单价和金额应在 0 至 999,999,999 之间");
foreach(var i in items) if(i.Text("extraAttributes").Length>0)
{ try { using var doc=System.Text.Json.JsonDocument.Parse(i.Text("extraAttributes"));if(doc.RootElement.ValueKind!=System.Text.Json.JsonValueKind.Object)throw new ArgumentException("扩展属性必须是 JSON 对象"); }catch(System.Text.Json.JsonException){throw new ArgumentException("扩展属性 JSON 格式不正确");} }
}
public string OpenPawn(DbRow ticket,IReadOnlyList<DbRow> items,int days)
{
Positive(ticket.Money("principal"),"当金"); ValidItems(items);
if(days<1 || days>3650) throw new ArgumentException("当期应在 1 至 3650 天之间");
foreach(var rate in new[]{"monthlyRate","storageFeeRate","feeRate"}) if(ticket.Money(rate)<0 || ticket.Money(rate)>100) throw new ArgumentException("费率应在 0% 至 100% 之间");
if(ticket.Money("fixedInterest")<0 || ticket.Money("storageFixedFee")<0) throw new ArgumentException("固定费用不能为负数");
var fees=Fees.Period(ticket,days,days);
return db.Write(c=>
{
var customer=RequireCustomer(c,ticket.Text("customerId"));
ticket["id"]=Database.Id("pawn"); ticket["ticketNo"]=Number(c,"PawnTicket","ticketNo","P");
ticket["customerName"]=customer.Text("name"); ticket["customerPhone"]=customer.Text("phone");
ticket["itemName"]=string.Join("、",items.Select(i=>i.Text("name"))); ticket["itemType"]=items[0].Text("type","OTHER");
ticket["appraisalPrice"]=items.Sum(i=>i.Money("appraisalPrice")); ticket["periodMonths"]=(decimal)days/30;
ticket["dueDate"]=Display.Day(ticket.Date("pawnDate").AddDays(days)); ticket["status"]="ACTIVE";
ticket["createdAt"]=Database.Now; ticket["updatedAt"]=Database.Now;
Database.Upsert(c,"PawnTicket",ticket);
foreach(var item in items) { item["id"]=Database.Id("item"); item["ticketId"]=ticket.Id; item["createdAt"]=Database.Now; Database.Upsert(c,"PawnItem",item); }
Finance(c,"EXPENSE","PAWN_OUT",ticket.Money("principal"),ticket.Id,ticket.Text("pawnDate"),"开当放款 "+ticket.Text("ticketNo"));
if(ticket.Text("feeChargeMode")!="POSTPAID") PostFees(c,fees,ticket.Id,ticket.Text("pawnDate"),"开当费用");
Database.Log(c,"PAWN_OPEN","PawnTicket",ticket.Id,ticket.Text("ticketNo"));
return ticket.Id;
});
}
private static DbRow Active(SqliteConnection c,string id) => Database.Query(c,"SELECT * FROM PawnTicket WHERE id=@p0 AND status='ACTIVE' AND deletedAt IS NULL",id).FirstOrDefault() ?? throw new InvalidOperationException("仅活当可执行此操作,记录可能已经处理");
public FeeBreakdown RedeemPreview(string id,DateTime date)
{
var t=db.Find("PawnTicket",id)??throw new ArgumentException("当单不存在");
var renews=db.Query("SELECT * FROM RenewRecord WHERE ticketId=@p0",id);
ValidatePawnDate(t,renews,date,"赎当");
return Fees.Redeem(t,date,Shop.Money("penaltyRate"),Fees.CurrentPeriod(t,renews));
}
public void Redeem(string id,DateTime date,decimal paid)
{
if(paid<0) throw new ArgumentException("实收金额不能为负数");
db.Write(c=>
{
var ticket=Active(c,id); var shop=Database.Query(c,"SELECT * FROM Shop LIMIT 1").First();
var renews=Database.Query(c,"SELECT * FROM RenewRecord WHERE ticketId=@p0",id);ValidatePawnDate(ticket,renews,date,"赎当");
var fee=Fees.Redeem(ticket,date,shop.Money("penaltyRate"),Fees.CurrentPeriod(ticket,renews));
var expected=ticket.Money("principal")+fee.Total;
Database.Upsert(c,"RedeemRecord",new DbRow { ["id"]=Database.Id("redeem"), ["ticketId"]=id, ["redeemDate"]=Display.Day(date), ["principal"]=ticket.Money("principal"), ["interest"]=fee.Interest, ["storageFee"]=fee.Storage, ["fee"]=fee.Service, ["penalty"]=fee.Penalty, ["paid"]=paid, ["remark"]=$"应收 {expected:F2};实收 {paid:F2}", ["createdAt"]=Database.Now });
Database.Execute(c,"UPDATE PawnTicket SET status='REDEEMED',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Finance(c,"INCOME","PRINCIPAL_IN",ticket.Money("principal"),id,Display.Day(date),"赎回本金"); PostFees(c,fee,id,Display.Day(date),"赎当费用");
if(paid!=expected) Finance(c,paid>expected?"INCOME":"EXPENSE",paid>expected?"OTHER_IN":"OTHER_OUT",Math.Abs(paid-expected),id,Display.Day(date),"赎当收款差额");
Database.Execute(c,"UPDATE Reminder SET status='DONE' WHERE ticketId=@p0",id);
Database.Log(c,"REDEEM","PawnTicket",id,ticket.Text("ticketNo")); return 0;
});
}
private static void ValidatePawnDate(DbRow ticket,IReadOnlyList<DbRow> renews,DateTime date,string action)
{
if(date.Date<ticket.Date("pawnDate").Date)throw new ArgumentException(action+"日期不能早于开当日期");
if(renews.Any(r=>date.Date<r.Date("renewDate").Date))throw new ArgumentException(action+"日期不能早于已有续当日期");
}
public void Renew(string id,int days,decimal? newPrincipal=null,DateTime? renewDate=null)
{
if(days<1 || days>3650) throw new ArgumentException("续当周期应在 1 至 3650 天之间");
if(newPrincipal.HasValue)Positive(newPrincipal.Value,"续当当金");
var actualDate=(renewDate??DateTime.Today).Date;
db.Write(c=>
{
var ticket=Active(c,id); var renews=Database.Query(c,"SELECT * FROM RenewRecord WHERE ticketId=@p0",id);
ValidatePawnDate(ticket,renews,actualDate,"续当");
var oldPrincipal=ticket.Money("principal");ticket["principal"]=newPrincipal??oldPrincipal;
var fees=Fees.Period(ticket,days,Fees.CurrentPeriod(ticket,renews)); var due=Fees.RenewDue(ticket.Date("dueDate"),days);
Database.Upsert(c,"RenewRecord",new DbRow { ["id"]=Database.Id("renew"), ["ticketId"]=id, ["renewDate"]=Display.Day(actualDate), ["oldDueDate"]=ticket.Text("dueDate"), ["newDueDate"]=Display.Day(due), ["newPrincipal"]=ticket.Money("principal"), ["storageFee"]=fees.Storage, ["fee"]=fees.Total, ["remark"]=$"续当 {days} 天", ["createdAt"]=Database.Now });
Database.Execute(c,"UPDATE PawnTicket SET dueDate=@p0,updatedAt=@p1,principal=@p3 WHERE id=@p2",Display.Day(due),Database.Now,id,ticket.Money("principal"));
var difference=ticket.Money("principal")-oldPrincipal;
if(difference!=0)Finance(c,difference>0?"EXPENSE":"INCOME",difference>0?"PAWN_OUT":"PRINCIPAL_IN",Math.Abs(difference),id,Display.Day(actualDate),difference>0?"续当增加放款":"续当收回部分本金");
PostFees(c,fees,id,Display.Day(actualDate),"续当费用");
Database.Execute(c,"UPDATE Reminder SET status='DONE' WHERE ticketId=@p0",id);
Database.Log(c,"RENEW","PawnTicket",id,ticket.Text("ticketNo")); return 0;
});
}
public void Dead(string id)
{
db.Write(c=>
{
var ticket=Active(c,id); var items=Database.Query(c,"SELECT * FROM PawnItem WHERE ticketId=@p0",id);
if(items.Count==0) items.Add(new DbRow { ["name"]=ticket.Text("itemName"), ["type"]=ticket.Text("itemType"), ["appraisalPrice"]=ticket.Money("appraisalPrice") });
var cost=Fees.DeadCost(ticket,DateTime.Today,Database.Query(c,"SELECT * FROM RenewRecord WHERE ticketId=@p0",id));
decimal total=items.Sum(i=>i.Money("appraisalPrice")),allocated=0;
for(int index=0;index<items.Count;index++)
{
var item=new DbRow(items[index]); var amount=index==items.Count-1?cost-allocated:Fees.Round(total>0?cost*item.Money("appraisalPrice")/total:cost/items.Count); allocated+=amount;
item["id"]=Database.Id("stock"); item["orderId"]=""; item["source"]="DEAD"; item["ticketId"]=id; item["status"]="IN_STOCK"; item["amount"]=amount; item["createdAt"]=Database.Now;
Database.Upsert(c,"RecycleItem",item);
}
Database.Execute(c,"UPDATE PawnTicket SET status='DEAD',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Database.Execute(c,"UPDATE Reminder SET status='DONE' WHERE ticketId=@p0",id);
Database.Log(c,"DEAD","PawnTicket",id,ticket.Text("ticketNo")); return 0;
});
}
public string Recycle(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"RECYCLE");
public string PurchaseStock(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"PURCHASE");
public string RentalStock(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"RENTAL");
public string OtherStock(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"OTHER");
private string Intake(DbRow order,IReadOnlyList<DbRow> items,string source)
{
ValidItems(items); var sum=items.Sum(i=>i.Money("amount")); Positive(sum,"回收金额");
return db.Write(c=>
{
var customer=IntakeCustomer(c,order);
order["customerId"]=customer.Id; order["source"]=source;
order["id"]=Database.Id("recycle"); order["orderNo"]=Number(c,"RecycleOrder","orderNo",source=="PURCHASE"?"IN":"RC"); order["customerName"]=customer.Text("name"); order["customerPhone"]=customer.Text("phone");
order["itemName"]=string.Join("、",items.Select(i=>i.Text("name"))); order["itemType"]=items[0].Text("type","OTHER"); order["amount"]=sum; order["status"]="DONE";
order["createdAt"]=Database.Now; order["updatedAt"]=Database.Now; Database.Upsert(c,"RecycleOrder",order);
foreach(var item in items) { item["id"]=Database.Id("stock"); item["orderId"]=order.Id; item["status"]="IN_STOCK"; item["source"]=source; item["createdAt"]=Database.Now; Database.Upsert(c,"RecycleItem",item); }
Finance(c,"EXPENSE",source=="RECYCLE"?"RECYCLE_OUT":"STOCK_OUT",sum,order.Id,order.Text("dealDate"),(source=="RECYCLE"?"回收入库 ":"购货入库 ")+order.Text("orderNo")); Database.Log(c,source=="PURCHASE"?"STOCK_PURCHASE":"RECYCLE","RecycleOrder",order.Id,order.Text("orderNo")); return order.Id;
});
}
private DbRow IntakeCustomer(SqliteConnection c,DbRow order)
{
if(order.Text("customerId").Length>0)return RequireCustomer(c,order.Text("customerId"));
var name=order.Text("customerName").Trim();var phone=order.Text("customerPhone").Trim();
if(name.Length==0 && phone.Length==0)return new DbRow{["name"]="",["phone"]=""};
if(phone.Length>0 && (phone.Count(char.IsDigit)<6 || !phone.All(ch=>char.IsDigit(ch)||ch is '+' or '-' or ' ')))throw new ArgumentException("请填写正确的联系电话");
string Normalize(string value)=>new(value.Where(char.IsDigit).ToArray());
var matches=Database.Query(c,"SELECT * FROM Customer WHERE deletedAt IS NULL").Where(r=>phone.Length>0?Normalize(r.Text("phone"))==Normalize(phone):r.Text("name")==name && r.Text("phone").Length==0).ToList();
if(matches.Count>1)throw new ArgumentException("已有多个客户使用此电话或姓名,请搜索并选择对应客户");
if(matches.Count==1)return RequireCustomer(c,matches[0].Id);
var customer=new DbRow{["id"]=Database.Id("customer"),["code"]=Number(c,"Customer","code","C"),["name"]=name.Length>0?name:phone,["phone"]=phone,["createdAt"]=Database.Now,["updatedAt"]=Database.Now};
Database.Upsert(c,"Customer",customer);Database.Log(c,"CUSTOMER_SAVE","Customer",customer.Id,customer.Text("name"),"开单自动建立客户档案");return customer;
}
public string Sell(string itemId,decimal amount,string customerId,string method,DateTime date)
{
Positive(amount,"销售金额");
return db.Write(c=>
{
var item=AvailableStock(c,itemId);
var customer=customerId.Length>0?RequireCustomer(c,customerId):new DbRow(); var id=Database.Id("sale");
Database.Upsert(c,"SaleRecord",new DbRow { ["id"]=id, ["saleNo"]=Number(c,"SaleRecord","saleNo","S"), ["itemId"]=itemId, ["customerId"]=customerId, ["customerName"]=customer.Text("name","散客"), ["amount"]=amount, ["cost"]=item.Money("amount"), ["profit"]=Fees.Round(amount-item.Money("amount")), ["payMethod"]=method, ["saleDate"]=Display.Day(date), ["status"]="DONE", ["createdAt"]=Database.Now, ["updatedAt"]=Database.Now });
Database.Execute(c,"UPDATE RecycleItem SET status='SOLD',soldAt=@p0 WHERE id=@p1",Display.Day(date),itemId);
Finance(c,"INCOME","SALE_IN",amount,id,Display.Day(date),"库存销售 "+item.Text("name")); Database.Log(c,"SALE","RecycleItem",itemId,item.Text("name")); return id;
});
}
public void ListStock(string id,decimal price) { Positive(price,"标价"); db.Write(c=> { var item=AvailableStock(c,id); Database.Execute(c,"UPDATE RecycleItem SET listedPrice=@p0,status='LISTED' WHERE id=@p1",price,id); Database.Log(c,"LIST","RecycleItem",id,item.Text("name")); return 0; }); }
public void VoidSale(string id)
{
db.Write(c=>
{
var sale=Database.Query(c,"SELECT * FROM SaleRecord WHERE id=@p0 AND status='DONE' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("该销售已经作废");
if(sale.Text("rentalOrderId").Length>0)throw new InvalidOperationException("未归还结算请在租赁单中移入回收站后还原,不能单独作废销售");
var item=Database.Query(c,"SELECT * FROM RecycleItem WHERE id=@p0 AND status='SOLD'",sale.Text("itemId")).FirstOrDefault()??throw new InvalidOperationException("库存状态异常,不能作废");
Database.Execute(c,"UPDATE SaleRecord SET status='VOID',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Database.Execute(c,"UPDATE RecycleItem SET status='IN_STOCK',soldAt=NULL WHERE id=@p0",item.Id);
Finance(c,"EXPENSE","SALE_REFUND",sale.Money("amount"),id,Display.Day(DateTime.Today),"销售作废退款 "+sale.Text("saleNo"));
Database.Log(c,"SALE_VOID","SaleRecord",id,sale.Text("saleNo"));return 0;
});
}
public void VoidIntake(string id)
{
db.Write(c=>
{
var order=Database.Query(c,"SELECT * FROM RecycleOrder WHERE id=@p0 AND status='DONE' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("入库单已经作废");
if(Database.Query(c,"SELECT id FROM RecycleItem WHERE orderId=@p0 AND status NOT IN ('IN_STOCK','LISTED')",id).Count>0)throw new InvalidOperationException("已有物品售出或处理,请先作废销售");
Database.Execute(c,"UPDATE RecycleOrder SET status='VOID',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Database.Execute(c,"UPDATE RecycleItem SET status='VOID' WHERE orderId=@p0",id);
Finance(c,"INCOME","STOCK_REFUND",order.Money("amount"),id,Display.Day(DateTime.Today),"入库作废退款 "+order.Text("orderNo"));
Database.Log(c,"INTAKE_VOID","RecycleOrder",id,order.Text("orderNo"));return 0;
});
}
public string Business(DbRow row,IReadOnlyList<DbRow>? items=null)
{
if(row.Text("kind") is not ("CONSIGN" or "RENT" or "OTHER")) throw new ArgumentException("业务类型无效");
if(items is not null)
{
ValidItems(items);foreach(var item in items)if(item.Text("itemCode").Length==0)item["itemCode"]=ItemCatalog.NewCode();
row["itemName"]=string.Join("、",items.Select(i=>i.Text("name")));row["itemType"]=items[0].Text("type","OTHER");
row["items"]=System.Text.Json.JsonSerializer.Serialize(items);
}
if(row.Text("itemName").Trim().Length==0) throw new ArgumentException("请填写物品名称");
if(row.Text("kind")=="CONSIGN")
{
Consignment.ValidateExpected(row.Text("expectedAmount"));var fees=Consignment.Calculate(row);row["commission"]=fees.Service;row["storageFee"]=fees.Storage;row["deposit"]=0;
}
else {Positive(row.Money("amount"),"金额");row["commission"]=0;row["storageFee"]=0;}
if(row.Money("deposit")<0 || row.Money("deposit")>999999999)throw new ArgumentException("押金应在 0 至 999,999,999 之间");
if(row.Text("dueDate").Length>0 && row.Date("dueDate")<row.Date("startDate"))throw new ArgumentException("到期日不能早于开始日期");
return db.Write(c=> { var customer=RequireCustomer(c,row.Text("customerId")); row["id"]=Database.Id("business"); row["orderNo"]=Number(c,"BusinessOrder","orderNo","B"); row["customerName"]=customer.Text("name"); row["customerPhone"]=customer.Text("phone"); row["createdAt"]=Database.Now; row["updatedAt"]=Database.Now; row["status"]="OPEN";if(row.Text("kind")=="RENT")RentalInventory.Attach(c,row,RentalInventory.Items(row),true); Database.Upsert(c,"BusinessOrder",row);if(row.Text("kind")=="RENT")Finance(c,"INCOME","DEPOSIT_IN",row.Money("deposit"),row.Id,row.Text("startDate"),"租赁押金"); Database.Log(c,"BUSINESS_OPEN","BusinessOrder",row.Id,row.Text("orderNo")); return row.Id; });
}
public void CompleteBusiness(string id,DbRow? settlement=null)
{
db.Write(c=>
{
var row=Database.Query(c,"SELECT * FROM BusinessOrder WHERE id=@p0 AND status='OPEN' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("该业务已处理");
var kind=row.Text("kind");var day=Display.Day(DateTime.Today);
if(kind=="CONSIGN")
{
if(settlement is not null)foreach(var key in new[]{"amount","serviceFeeMode","serviceFeeValue","storageFee"})if(settlement.ContainsKey(key))row[key]=settlement[key];
var fees=Consignment.Calculate(row,true);row["commission"]=fees.Service;row["storageFee"]=fees.Storage;
Finance(c,"INCOME","CONSIGN_SALE",row.Money("amount"),id,day,row.Text("itemName"));
Finance(c,"EXPENSE","CONSIGN_PAYOUT",fees.Proceeds,id,day,"寄卖结算支付客户");
}
else
{
if(kind=="RENT" && RentalInventory.Items(row).Any(i=>i.Text("stockId").Length>0))ReturnRentalItems(c,row);
Finance(c,"INCOME",kind=="RENT"?"RENT_IN":"OTHER_IN",row.Money("amount"),id,day,row.Text("itemName"));
if(kind=="RENT")Finance(c,"EXPENSE","DEPOSIT_REFUND",row.Money("deposit"),id,day,"租赁押金退回");
}
row["status"]=kind=="RENT"?"RETURNED":"DONE";row["endDate"]=day;row["updatedAt"]=Database.Now;Database.Upsert(c,"BusinessOrder",row);
Database.Log(c,"BUSINESS_CLOSE","BusinessOrder",id,row.Text("orderNo"));return 0;
});
}
public void VoidBusiness(string id)=>db.Write(c=>{var row=Database.Query(c,"SELECT * FROM BusinessOrder WHERE id=@p0 AND status='OPEN' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("仅进行中的业务可作废");if(row.Text("kind")=="RENT"){ReturnRentalItems(c,row);Finance(c,"EXPENSE","DEPOSIT_REFUND",row.Money("deposit"),id,Display.Day(DateTime.Today),"作废租赁退还押金");}Database.Execute(c,"UPDATE BusinessOrder SET status='VOID',endDate=@p0,updatedAt=@p1 WHERE id=@p2",Display.Day(DateTime.Today),Database.Now,id);Database.Log(c,"BUSINESS_VOID","BusinessOrder",id,row.Text("orderNo"));return 0;});
public string Book(DbRow row)
{
Positive(row.Money("amount"),"金额"); if(row.Text("type") is not ("INCOME" or "EXPENSE")) throw new ArgumentException("请选择收支方向");
if(row.Text("majorClass")=="STORE")
{
if(!ManualFinance.Details("STORE",row.Text("type")).ContainsKey(row.Text("detailClass")))throw new ArgumentException("店铺收入请选择店铺其他;房租、水电网、工资与资金成本计入支出");
row["category"]=ManualFinance.StoreCategory(row.Text("detailClass"),row.Text("type"));
}
if(row.Text("category").Length==0) throw new ArgumentException("请选择财务分类");
if(db.Query("SELECT id FROM FinanceCategory WHERE code=@p0 AND type=@p1 AND enabled=1",row.Text("category"),row.Text("type")).Count==0)throw new ArgumentException("请选择与收支方向一致的财务分类");
if(row.Text("majorClass").Length==0)
{
var classification=FinanceCategories.Classification(row.Text("category"));row["majorClass"]=classification.Major;row["detailClass"]=classification.Detail;
}
if(row.Text("category")=="FUNDING_COST"){row["majorClass"]="STORE";row["detailClass"]="FUNDING_COST";}
if(row.Text("majorClass").Length>0 && !FinanceAnalytics.Majors.ContainsKey(row.Text("majorClass")))throw new ArgumentException("请选择财务大类");
if(row.Text("detailClass").Length>0 && !FinanceAnalytics.Details(row.Text("majorClass")).ContainsKey(row.Text("detailClass")) && !(row.Text("majorClass")=="OTHER"&&row.Text("detailClass")==row.Text("category")&&row.Text("category").StartsWith("CUSTOM_",StringComparison.Ordinal)))throw new ArgumentException("请选择该大类的细类");
return db.Write(c=> { row["id"]=Database.Id("finance"); row["createdAt"]=Database.Now; Database.Upsert(c,"FinanceRecord",row); Database.Log(c,"BOOK","FinanceRecord",row.Id,row.Text("remark")); return row.Id; });
}
private static void PostFees(SqliteConnection c,FeeBreakdown fees,string id,string day,string remark)
{
Finance(c,"INCOME","INTEREST",fees.Interest,id,day,remark); Finance(c,"INCOME","STORAGE_FEE",fees.Storage,id,day,remark); Finance(c,"INCOME","C_FEE",fees.Service,id,day,remark); Finance(c,"INCOME","PENALTY_IN",fees.Penalty,id,day,remark);
}
private static void Finance(SqliteConnection c,string type,string category,decimal amount,string id,string day,string remark)
{
if(amount<=0) return;
var row=new DbRow { ["id"]=Database.Id("finance"), ["type"]=type, ["category"]=category, ["amount"]=Fees.Round(amount), ["ticketId"]=id, ["occurDate"]=day, ["remark"]=remark, ["createdAt"]=Database.Now };
row["allocations"]=FinanceAnalytics.Snapshot(c,row);Database.Upsert(c,"FinanceRecord",row);
}
public decimal Profit(DateTime start,DateTime end) => FinanceAnalytics.Read(db,new(start.Date,end.Date.AddDays(-1))).Profit;
}
using Microsoft.Data.Sqlite;
namespace JiDangJia.Core;
public sealed partial class BusinessService(Database db)
{
public Database Db => db;
public DbRow Shop => db.Query("SELECT * FROM Shop LIMIT 1").First();
public DbRow Owner => db.Query("SELECT * FROM User WHERE active=1 ORDER BY (role='店长') DESC,(id='user_owner') DESC LIMIT 1").FirstOrDefault() ?? new DbRow { ["name"]="店长" };
public string Category(string code)=>db.Query("SELECT label FROM FinanceCategory WHERE code=@p0 ORDER BY enabled DESC LIMIT 1",code).FirstOrDefault()?.Text("label")??Display.Category(code);
public string ItemType(string code)=>db.Query("SELECT label FROM PawnItemTypeRate WHERE itemType=@p0 ORDER BY enabled DESC LIMIT 1",code).FirstOrDefault()?.Text("label")??Display.Item(code);
public string Number(SqliteConnection c,string table,string field,string prefix)
{
var head=prefix+DateTime.Today.ToString("yyyyMMdd");
var last=Database.Query(c,$"SELECT [{field}] FROM [{table}] WHERE [{field}] LIKE @p0 ORDER BY [{field}] DESC LIMIT 1",head+"%").FirstOrDefault()?.Text(field);
int next=last is not null && int.TryParse(last[head.Length..],out var n)?n+1:1;
return head+next.ToString("D4");
}
public string Customer(DbRow row)
{
if (string.IsNullOrWhiteSpace(row.Text("name"))) throw new ArgumentException("请填写客户姓名");
if(row.Text("phone").Length>0 && (row.Text("phone").Length<6 || !row.Text("phone").All(c=>char.IsDigit(c)||c is '+' or '-' or ' '))) throw new ArgumentException("请填写正确的联系电话");
return db.Write(c=>
{
if(row.Id.Length==0) { row["id"]=Database.Id("customer"); row["code"]=Number(c,"Customer","code","C"); row["createdAt"]=Database.Now; }
row["updatedAt"]=Database.Now; Database.Upsert(c,"Customer",row); Database.Log(c,"CUSTOMER_SAVE","Customer",row.Id,row.Text("name")); return row.Id;
});
}
private DbRow RequireCustomer(SqliteConnection c,string id)
{
var customer=Database.Query(c,"SELECT * FROM Customer WHERE id=@p0 AND deletedAt IS NULL",id).FirstOrDefault() ?? throw new ArgumentException("请选择客户");
if(customer.Int("isBlacklist")==1) throw new ArgumentException("该客户已加入黑名单,请先核实客户信用");
return customer;
}
public static void Positive(decimal amount,string label) { if(amount<=0 || amount>999999999) throw new ArgumentException(label+"应大于零且不超过 999,999,999"); }
private static void ValidItems(IReadOnlyList<DbRow> items)
{
if(items.Count==0 || items.Any(i=>string.IsNullOrWhiteSpace(i.Text("name")))) throw new ArgumentException("请填写物品名称");
if(items.Any(i=>new[]{"weight","amount","appraisalPrice","unitPrice","quotedPrice"}.Any(k=>i.Money(k)<0 || i.Money(k)>999999999))) throw new ArgumentException("重量、单价和金额应在 0 至 999,999,999 之间");
foreach(var i in items) if(i.Text("extraAttributes").Length>0)
{ try { using var doc=System.Text.Json.JsonDocument.Parse(i.Text("extraAttributes"));if(doc.RootElement.ValueKind!=System.Text.Json.JsonValueKind.Object)throw new ArgumentException("扩展属性必须是 JSON 对象"); }catch(System.Text.Json.JsonException){throw new ArgumentException("扩展属性 JSON 格式不正确");} }
}
public string OpenPawn(DbRow ticket,IReadOnlyList<DbRow> items,int days)
{
Positive(ticket.Money("principal"),"当金"); ValidItems(items);
if(days<1 || days>3650) throw new ArgumentException("当期应在 1 至 3650 天之间");
foreach(var rate in new[]{"monthlyRate","storageFeeRate","feeRate"}) if(ticket.Money(rate)<0 || ticket.Money(rate)>100) throw new ArgumentException("费率应在 0% 至 100% 之间");
if(ticket.Money("fixedInterest")<0 || ticket.Money("storageFixedFee")<0) throw new ArgumentException("固定费用不能为负数");
var fees=Fees.Period(ticket,days,days);
return db.Write(c=>
{
var customer=RequireCustomer(c,ticket.Text("customerId"));
ticket["id"]=Database.Id("pawn"); ticket["ticketNo"]=Number(c,"PawnTicket","ticketNo","P");
ticket["customerName"]=customer.Text("name"); ticket["customerPhone"]=customer.Text("phone");
ticket["itemName"]=string.Join("、",items.Select(i=>i.Text("name"))); ticket["itemType"]=items[0].Text("type","OTHER");
ticket["appraisalPrice"]=items.Sum(i=>i.Money("appraisalPrice")); ticket["periodMonths"]=(decimal)days/30;
ticket["dueDate"]=Display.Day(ticket.Date("pawnDate").AddDays(days)); ticket["status"]="ACTIVE";
ticket["createdAt"]=Database.Now; ticket["updatedAt"]=Database.Now;
Database.Upsert(c,"PawnTicket",ticket);
foreach(var item in items) { item["id"]=Database.Id("item"); item["ticketId"]=ticket.Id; item["createdAt"]=Database.Now; Database.Upsert(c,"PawnItem",item); }
Finance(c,"EXPENSE","PAWN_OUT",ticket.Money("principal"),ticket.Id,ticket.Text("pawnDate"),"开当放款 "+ticket.Text("ticketNo"));
if(ticket.Text("feeChargeMode")!="POSTPAID") PostFees(c,fees,ticket.Id,ticket.Text("pawnDate"),"开当费用");
Database.Log(c,"PAWN_OPEN","PawnTicket",ticket.Id,ticket.Text("ticketNo"));
return ticket.Id;
});
}
private static DbRow Active(SqliteConnection c,string id) => Database.Query(c,"SELECT * FROM PawnTicket WHERE id=@p0 AND status='ACTIVE' AND deletedAt IS NULL",id).FirstOrDefault() ?? throw new InvalidOperationException("仅活当可执行此操作,记录可能已经处理");
public FeeBreakdown RedeemPreview(string id,DateTime date)
{
var t=db.Find("PawnTicket",id)??throw new ArgumentException("当单不存在");
var renews=db.Query("SELECT * FROM RenewRecord WHERE ticketId=@p0",id);
ValidatePawnDate(t,renews,date,"赎当");
return Fees.Redeem(t,date,Shop.Money("penaltyRate"),Fees.CurrentPeriod(t,renews));
}
public void Redeem(string id,DateTime date,decimal paid)
{
if(paid<0) throw new ArgumentException("实收金额不能为负数");
db.Write(c=>
{
var ticket=Active(c,id); var shop=Database.Query(c,"SELECT * FROM Shop LIMIT 1").First();
var renews=Database.Query(c,"SELECT * FROM RenewRecord WHERE ticketId=@p0",id);ValidatePawnDate(ticket,renews,date,"赎当");
var fee=Fees.Redeem(ticket,date,shop.Money("penaltyRate"),Fees.CurrentPeriod(ticket,renews));
var expected=ticket.Money("principal")+fee.Total;
Database.Upsert(c,"RedeemRecord",new DbRow { ["id"]=Database.Id("redeem"), ["ticketId"]=id, ["redeemDate"]=Display.Day(date), ["principal"]=ticket.Money("principal"), ["interest"]=fee.Interest, ["storageFee"]=fee.Storage, ["fee"]=fee.Service, ["penalty"]=fee.Penalty, ["paid"]=paid, ["remark"]=$"应收 {expected:F2};实收 {paid:F2}", ["createdAt"]=Database.Now });
Database.Execute(c,"UPDATE PawnTicket SET status='REDEEMED',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Finance(c,"INCOME","PRINCIPAL_IN",ticket.Money("principal"),id,Display.Day(date),"赎回本金"); PostFees(c,fee,id,Display.Day(date),"赎当费用");
if(paid!=expected) Finance(c,paid>expected?"INCOME":"EXPENSE",paid>expected?"OTHER_IN":"OTHER_OUT",Math.Abs(paid-expected),id,Display.Day(date),"赎当收款差额");
Database.Execute(c,"UPDATE Reminder SET status='DONE' WHERE ticketId=@p0",id);
Database.Log(c,"REDEEM","PawnTicket",id,ticket.Text("ticketNo")); return 0;
});
}
private static void ValidatePawnDate(DbRow ticket,IReadOnlyList<DbRow> renews,DateTime date,string action)
{
if(date.Date<ticket.Date("pawnDate").Date)throw new ArgumentException(action+"日期不能早于开当日期");
if(renews.Any(r=>date.Date<r.Date("renewDate").Date))throw new ArgumentException(action+"日期不能早于已有续当日期");
}
public void Renew(string id,int days,decimal? newPrincipal=null,DateTime? renewDate=null)
{
if(days<1 || days>3650) throw new ArgumentException("续当周期应在 1 至 3650 天之间");
if(newPrincipal.HasValue)Positive(newPrincipal.Value,"续当当金");
var actualDate=(renewDate??DateTime.Today).Date;
db.Write(c=>
{
var ticket=Active(c,id); var renews=Database.Query(c,"SELECT * FROM RenewRecord WHERE ticketId=@p0",id);
ValidatePawnDate(ticket,renews,actualDate,"续当");
var oldPrincipal=ticket.Money("principal");ticket["principal"]=newPrincipal??oldPrincipal;
var fees=Fees.Period(ticket,days,Fees.CurrentPeriod(ticket,renews)); var due=Fees.RenewDue(ticket.Date("dueDate"),days);
Database.Upsert(c,"RenewRecord",new DbRow { ["id"]=Database.Id("renew"), ["ticketId"]=id, ["renewDate"]=Display.Day(actualDate), ["oldDueDate"]=ticket.Text("dueDate"), ["newDueDate"]=Display.Day(due), ["newPrincipal"]=ticket.Money("principal"), ["storageFee"]=fees.Storage, ["fee"]=fees.Total, ["remark"]=$"续当 {days} 天", ["createdAt"]=Database.Now });
Database.Execute(c,"UPDATE PawnTicket SET dueDate=@p0,updatedAt=@p1,principal=@p3 WHERE id=@p2",Display.Day(due),Database.Now,id,ticket.Money("principal"));
var difference=ticket.Money("principal")-oldPrincipal;
if(difference!=0)Finance(c,difference>0?"EXPENSE":"INCOME",difference>0?"PAWN_OUT":"PRINCIPAL_IN",Math.Abs(difference),id,Display.Day(actualDate),difference>0?"续当增加放款":"续当收回部分本金");
PostFees(c,fees,id,Display.Day(actualDate),"续当费用");
Database.Execute(c,"UPDATE Reminder SET status='DONE' WHERE ticketId=@p0",id);
Database.Log(c,"RENEW","PawnTicket",id,ticket.Text("ticketNo")); return 0;
});
}
public void Dead(string id)
{
db.Write(c=>
{
var ticket=Active(c,id); var items=Database.Query(c,"SELECT * FROM PawnItem WHERE ticketId=@p0",id);
if(items.Count==0) items.Add(new DbRow { ["name"]=ticket.Text("itemName"), ["type"]=ticket.Text("itemType"), ["appraisalPrice"]=ticket.Money("appraisalPrice") });
var cost=Fees.DeadCost(ticket,DateTime.Today,Database.Query(c,"SELECT * FROM RenewRecord WHERE ticketId=@p0",id));
decimal total=items.Sum(i=>i.Money("appraisalPrice")),allocated=0;
for(int index=0;index<items.Count;index++)
{
var item=new DbRow(items[index]); var amount=index==items.Count-1?cost-allocated:Fees.Round(total>0?cost*item.Money("appraisalPrice")/total:cost/items.Count); allocated+=amount;
item["id"]=Database.Id("stock"); item["orderId"]=""; item["source"]="DEAD"; item["ticketId"]=id; item["status"]="IN_STOCK"; item["amount"]=amount; item["createdAt"]=Database.Now;
Database.Upsert(c,"RecycleItem",item);
}
Database.Execute(c,"UPDATE PawnTicket SET status='DEAD',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Database.Execute(c,"UPDATE Reminder SET status='DONE' WHERE ticketId=@p0",id);
Database.Log(c,"DEAD","PawnTicket",id,ticket.Text("ticketNo")); return 0;
});
}
public string Recycle(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"RECYCLE");
public string PurchaseStock(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"PURCHASE");
public string RentalStock(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"RENTAL");
public string OtherStock(DbRow order,IReadOnlyList<DbRow> items) => Intake(order,items,"OTHER");
private string Intake(DbRow order,IReadOnlyList<DbRow> items,string source)
{
ValidItems(items); var sum=items.Sum(i=>i.Money("amount")); Positive(sum,"回收金额");
return db.Write(c=>
{
var customer=IntakeCustomer(c,order);
order["customerId"]=customer.Id; order["source"]=source;
order["id"]=Database.Id("recycle"); order["orderNo"]=Number(c,"RecycleOrder","orderNo",source=="PURCHASE"?"IN":"RC"); order["customerName"]=customer.Text("name"); order["customerPhone"]=customer.Text("phone");
order["itemName"]=string.Join("、",items.Select(i=>i.Text("name"))); order["itemType"]=items[0].Text("type","OTHER"); order["amount"]=sum; order["status"]="DONE";
order["createdAt"]=Database.Now; order["updatedAt"]=Database.Now; Database.Upsert(c,"RecycleOrder",order);
foreach(var item in items) { item["id"]=Database.Id("stock"); item["orderId"]=order.Id; item["status"]="IN_STOCK"; item["source"]=source; item["createdAt"]=Database.Now; Database.Upsert(c,"RecycleItem",item); }
Finance(c,"EXPENSE",source=="RECYCLE"?"RECYCLE_OUT":"STOCK_OUT",sum,order.Id,order.Text("dealDate"),(source=="RECYCLE"?"回收入库 ":"购货入库 ")+order.Text("orderNo")); Database.Log(c,source=="PURCHASE"?"STOCK_PURCHASE":"RECYCLE","RecycleOrder",order.Id,order.Text("orderNo")); return order.Id;
});
}
private DbRow IntakeCustomer(SqliteConnection c,DbRow order)
{
if(order.Text("customerId").Length>0)return RequireCustomer(c,order.Text("customerId"));
var name=order.Text("customerName").Trim();var phone=order.Text("customerPhone").Trim();
if(name.Length==0 && phone.Length==0)return new DbRow{["name"]="",["phone"]=""};
if(phone.Length>0 && (phone.Count(char.IsDigit)<6 || !phone.All(ch=>char.IsDigit(ch)||ch is '+' or '-' or ' ')))throw new ArgumentException("请填写正确的联系电话");
string Normalize(string value)=>new(value.Where(char.IsDigit).ToArray());
var matches=Database.Query(c,"SELECT * FROM Customer WHERE deletedAt IS NULL").Where(r=>phone.Length>0?Normalize(r.Text("phone"))==Normalize(phone):r.Text("name")==name && r.Text("phone").Length==0).ToList();
if(matches.Count>1)throw new ArgumentException("已有多个客户使用此电话或姓名,请搜索并选择对应客户");
if(matches.Count==1)return RequireCustomer(c,matches[0].Id);
var customer=new DbRow{["id"]=Database.Id("customer"),["code"]=Number(c,"Customer","code","C"),["name"]=name.Length>0?name:phone,["phone"]=phone,["createdAt"]=Database.Now,["updatedAt"]=Database.Now};
Database.Upsert(c,"Customer",customer);Database.Log(c,"CUSTOMER_SAVE","Customer",customer.Id,customer.Text("name"),"开单自动建立客户档案");return customer;
}
public string Sell(string itemId,decimal amount,string customerId,string method,DateTime date)
{
Positive(amount,"销售金额");
return db.Write(c=>
{
var item=AvailableStock(c,itemId);
var customer=customerId.Length>0?RequireCustomer(c,customerId):new DbRow(); var id=Database.Id("sale");
Database.Upsert(c,"SaleRecord",new DbRow { ["id"]=id, ["saleNo"]=Number(c,"SaleRecord","saleNo","S"), ["itemId"]=itemId, ["customerId"]=customerId, ["customerName"]=customer.Text("name","散客"), ["amount"]=amount, ["cost"]=item.Money("amount"), ["profit"]=Fees.Round(amount-item.Money("amount")), ["payMethod"]=method, ["saleDate"]=Display.Day(date), ["status"]="DONE", ["createdAt"]=Database.Now, ["updatedAt"]=Database.Now });
Database.Execute(c,"UPDATE RecycleItem SET status='SOLD',soldAt=@p0 WHERE id=@p1",Display.Day(date),itemId);
Finance(c,"INCOME","SALE_IN",amount,id,Display.Day(date),"库存销售 "+item.Text("name")); Database.Log(c,"SALE","RecycleItem",itemId,item.Text("name")); return id;
});
}
public void ListStock(string id,decimal price) { Positive(price,"标价"); db.Write(c=> { var item=AvailableStock(c,id); Database.Execute(c,"UPDATE RecycleItem SET listedPrice=@p0,status='LISTED' WHERE id=@p1",price,id); Database.Log(c,"LIST","RecycleItem",id,item.Text("name")); return 0; }); }
public void VoidSale(string id)
{
db.Write(c=>
{
var sale=Database.Query(c,"SELECT * FROM SaleRecord WHERE id=@p0 AND status='DONE' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("该销售已经作废");
if(sale.Text("rentalOrderId").Length>0)throw new InvalidOperationException("未归还结算请在租赁单中移入回收站后还原,不能单独作废销售");
var item=Database.Query(c,"SELECT * FROM RecycleItem WHERE id=@p0 AND status='SOLD'",sale.Text("itemId")).FirstOrDefault()??throw new InvalidOperationException("库存状态异常,不能作废");
Database.Execute(c,"UPDATE SaleRecord SET status='VOID',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Database.Execute(c,"UPDATE RecycleItem SET status='IN_STOCK',soldAt=NULL WHERE id=@p0",item.Id);
Finance(c,"EXPENSE","SALE_REFUND",sale.Money("amount"),id,Display.Day(DateTime.Today),"销售作废退款 "+sale.Text("saleNo"));
Database.Log(c,"SALE_VOID","SaleRecord",id,sale.Text("saleNo"));return 0;
});
}
public void VoidIntake(string id)
{
db.Write(c=>
{
var order=Database.Query(c,"SELECT * FROM RecycleOrder WHERE id=@p0 AND status='DONE' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("入库单已经作废");
if(Database.Query(c,"SELECT id FROM RecycleItem WHERE orderId=@p0 AND status NOT IN ('IN_STOCK','LISTED')",id).Count>0)throw new InvalidOperationException("已有物品售出或处理,请先作废销售");
Database.Execute(c,"UPDATE RecycleOrder SET status='VOID',updatedAt=@p0 WHERE id=@p1",Database.Now,id);
Database.Execute(c,"UPDATE RecycleItem SET status='VOID' WHERE orderId=@p0",id);
Finance(c,"INCOME","STOCK_REFUND",order.Money("amount"),id,Display.Day(DateTime.Today),"入库作废退款 "+order.Text("orderNo"));
Database.Log(c,"INTAKE_VOID","RecycleOrder",id,order.Text("orderNo"));return 0;
});
}
public string Business(DbRow row,IReadOnlyList<DbRow>? items=null)
{
if(row.Text("kind") is not ("CONSIGN" or "RENT" or "OTHER")) throw new ArgumentException("业务类型无效");
if(items is not null)
{
ValidItems(items);foreach(var item in items)if(item.Text("itemCode").Length==0)item["itemCode"]=ItemCatalog.NewCode();
row["itemName"]=string.Join("、",items.Select(i=>i.Text("name")));row["itemType"]=items[0].Text("type","OTHER");
row["items"]=System.Text.Json.JsonSerializer.Serialize(items);
}
if(row.Text("itemName").Trim().Length==0) throw new ArgumentException("请填写物品名称");
if(row.Text("kind")=="CONSIGN")
{
Consignment.ValidateExpected(row.Text("expectedAmount"));var fees=Consignment.Calculate(row);row["commission"]=fees.Service;row["storageFee"]=fees.Storage;row["deposit"]=0;
}
else {Positive(row.Money("amount"),"金额");row["commission"]=0;row["storageFee"]=0;}
if(row.Money("deposit")<0 || row.Money("deposit")>999999999)throw new ArgumentException("押金应在 0 至 999,999,999 之间");
if(row.Text("dueDate").Length>0 && row.Date("dueDate")<row.Date("startDate"))throw new ArgumentException("到期日不能早于开始日期");
return db.Write(c=> { var customer=RequireCustomer(c,row.Text("customerId")); row["id"]=Database.Id("business"); row["orderNo"]=Number(c,"BusinessOrder","orderNo","B"); row["customerName"]=customer.Text("name"); row["customerPhone"]=customer.Text("phone"); row["createdAt"]=Database.Now; row["updatedAt"]=Database.Now; row["status"]="OPEN";if(row.Text("kind")=="RENT")RentalInventory.Attach(c,row,RentalInventory.Items(row),true); Database.Upsert(c,"BusinessOrder",row);if(row.Text("kind")=="RENT")Finance(c,"INCOME","DEPOSIT_IN",row.Money("deposit"),row.Id,row.Text("startDate"),"租赁押金"); Database.Log(c,"BUSINESS_OPEN","BusinessOrder",row.Id,row.Text("orderNo")); return row.Id; });
}
public void CompleteBusiness(string id,DbRow? settlement=null)
{
db.Write(c=>
{
var row=Database.Query(c,"SELECT * FROM BusinessOrder WHERE id=@p0 AND status='OPEN' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("该业务已处理");
var kind=row.Text("kind");var day=Display.Day(DateTime.Today);
if(kind=="CONSIGN")
{
if(settlement is not null)foreach(var key in new[]{"amount","serviceFeeMode","serviceFeeValue","storageFee"})if(settlement.ContainsKey(key))row[key]=settlement[key];
var fees=Consignment.Calculate(row,true);row["commission"]=fees.Service;row["storageFee"]=fees.Storage;
Finance(c,"INCOME","CONSIGN_SALE",row.Money("amount"),id,day,row.Text("itemName"));
Finance(c,"EXPENSE","CONSIGN_PAYOUT",fees.Proceeds,id,day,"寄卖结算支付客户");
}
else
{
if(kind=="RENT" && RentalInventory.Items(row).Any(i=>i.Text("stockId").Length>0))ReturnRentalItems(c,row);
Finance(c,"INCOME",kind=="RENT"?"RENT_IN":"OTHER_IN",row.Money("amount"),id,day,row.Text("itemName"));
if(kind=="RENT")Finance(c,"EXPENSE","DEPOSIT_REFUND",row.Money("deposit"),id,day,"租赁押金退回");
}
row["status"]=kind=="RENT"?"RETURNED":"DONE";row["endDate"]=day;row["updatedAt"]=Database.Now;Database.Upsert(c,"BusinessOrder",row);
Database.Log(c,"BUSINESS_CLOSE","BusinessOrder",id,row.Text("orderNo"));return 0;
});
}
public void VoidBusiness(string id)=>db.Write(c=>{var row=Database.Query(c,"SELECT * FROM BusinessOrder WHERE id=@p0 AND status='OPEN' AND deletedAt IS NULL",id).FirstOrDefault()??throw new InvalidOperationException("仅进行中的业务可作废");if(row.Text("kind")=="RENT"){ReturnRentalItems(c,row);Finance(c,"EXPENSE","DEPOSIT_REFUND",row.Money("deposit"),id,Display.Day(DateTime.Today),"作废租赁退还押金");}Database.Execute(c,"UPDATE BusinessOrder SET status='VOID',endDate=@p0,updatedAt=@p1 WHERE id=@p2",Display.Day(DateTime.Today),Database.Now,id);Database.Log(c,"BUSINESS_VOID","BusinessOrder",id,row.Text("orderNo"));return 0;});
public string Book(DbRow row)
{
Positive(row.Money("amount"),"金额"); if(row.Text("type") is not ("INCOME" or "EXPENSE")) throw new ArgumentException("请选择收支方向");
if(row.Text("majorClass")=="STORE")
{
if(!ManualFinance.Details("STORE",row.Text("type")).ContainsKey(row.Text("detailClass")))throw new ArgumentException("店铺收入请选择店铺其他;房租、水电网、工资与资金成本计入支出");
row["category"]=ManualFinance.StoreCategory(row.Text("detailClass"),row.Text("type"));
}
if(row.Text("category").Length==0) throw new ArgumentException("请选择财务分类");
if(db.Query("SELECT id FROM FinanceCategory WHERE code=@p0 AND type=@p1 AND enabled=1",row.Text("category"),row.Text("type")).Count==0)throw new ArgumentException("请选择与收支方向一致的财务分类");
if(row.Text("majorClass").Length==0)
{
var classification=FinanceCategories.Classification(row.Text("category"));row["majorClass"]=classification.Major;row["detailClass"]=classification.Detail;
}
if(row.Text("category")=="FUNDING_COST"){row["majorClass"]="STORE";row["detailClass"]="FUNDING_COST";}
if(row.Text("majorClass").Length>0 && !FinanceAnalytics.Majors.ContainsKey(row.Text("majorClass")))throw new ArgumentException("请选择财务大类");
if(row.Text("detailClass").Length>0 && !FinanceAnalytics.Details(row.Text("majorClass")).ContainsKey(row.Text("detailClass")) && !(row.Text("majorClass")=="OTHER"&&row.Text("detailClass")==row.Text("category")&&row.Text("category").StartsWith("CUSTOM_",StringComparison.Ordinal)))throw new ArgumentException("请选择该大类的细类");
return db.Write(c=> { row["id"]=Database.Id("finance"); row["createdAt"]=Database.Now; Database.Upsert(c,"FinanceRecord",row); Database.Log(c,"BOOK","FinanceRecord",row.Id,row.Text("remark")); return row.Id; });
}
private static void PostFees(SqliteConnection c,FeeBreakdown fees,string id,string day,string remark)
{
Finance(c,"INCOME","INTEREST",fees.Interest,id,day,remark); Finance(c,"INCOME","STORAGE_FEE",fees.Storage,id,day,remark); Finance(c,"INCOME","C_FEE",fees.Service,id,day,remark); Finance(c,"INCOME","PENALTY_IN",fees.Penalty,id,day,remark);
}
private static void Finance(SqliteConnection c,string type,string category,decimal amount,string id,string day,string remark)
{
if(amount<=0) return;
var row=new DbRow { ["id"]=Database.Id("finance"), ["type"]=type, ["category"]=category, ["amount"]=Fees.Round(amount), ["ticketId"]=id, ["occurDate"]=day, ["remark"]=remark, ["createdAt"]=Database.Now };
row["allocations"]=FinanceAnalytics.Snapshot(c,row);Database.Upsert(c,"FinanceRecord",row);
}
public decimal Profit(DateTime start,DateTime end) => FinanceAnalytics.Read(db,new(start.Date,end.Date.AddDays(-1))).Profit;
}