新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化
- Region 表新增热度列,热门省份优先显示 - 区县下拉按地级市分组、已录区县优先、支持全省搜索 - 组选择内置一组至二十组 - 银行卡号独立行+粗体预览,开户行独立行 - 新增农户类型:农户/个体户/合作社/经营集体/公司 - 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS - 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展 - 同名村弹窗提醒(防选错乡镇) - 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作 - 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BCrypt.Net-Next" Version="4.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<!-- Pomelo 官方分支,跟随 .NET 10 / EF Core 10 发布周期 -->
|
||||
<PackageReference Include="Microting.EntityFrameworkCore.MySql" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,18 @@
|
||||
@host = http://localhost:5246
|
||||
|
||||
### 登录(获取 Token)
|
||||
POST {{host}}/api/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "123456"
|
||||
}
|
||||
|
||||
### 大屏概览(请在 Header 中粘贴登录返回的 Token)
|
||||
GET {{host}}/api/dashboard/overview
|
||||
Authorization: Bearer <your-token>
|
||||
|
||||
### 收购单列表
|
||||
GET {{host}}/api/purchases?page=1&pageSize=10
|
||||
Authorization: Bearer <your-token>
|
||||
@@ -0,0 +1,122 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>管理功能:演示数据等</summary>
|
||||
[ApiController]
|
||||
[Route("api/admin")]
|
||||
[Authorize]
|
||||
public class AdminController(AppDbContext db, CurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 创建当日演示数据:随机生成 20-60 条蚕茧收购单(已完成)
|
||||
/// 等级为「正茧 / 双宫 / 黄斑 / 血茧」之一,其中 95% 概率为正茧。
|
||||
/// </summary>
|
||||
[HttpPost("demo-data")]
|
||||
public async Task<ActionResult> CreateDemoData()
|
||||
{
|
||||
var rnd = Random.Shared;
|
||||
var now = DateTime.Now;
|
||||
var todayStart = now.Date;
|
||||
|
||||
// 1. 品种:蚕茧(不存在则自动创建)
|
||||
var cocoon = await db.Products.FirstOrDefaultAsync(p => p.Name == "蚕茧");
|
||||
if (cocoon is null)
|
||||
{
|
||||
cocoon = new Product
|
||||
{
|
||||
Name = "蚕茧",
|
||||
Category = "经济作物",
|
||||
Unit = "公斤",
|
||||
Spec = "鲜茧含水率≤15%",
|
||||
Price = 30m,
|
||||
CreatedAt = now
|
||||
};
|
||||
db.Products.Add(cocoon);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
// 2. 农户(活跃)
|
||||
var farmerIds = await db.Farmers
|
||||
.Where(f => f.Status == FarmerStatus.Active)
|
||||
.Select(f => f.Id).ToListAsync();
|
||||
if (farmerIds.Count == 0)
|
||||
return BadRequest(new { message = "暂无农户数据,请先在「农户管理」中创建农户" });
|
||||
|
||||
// 3. 收购组织:优先当前用户所属组织(保证数据可见),否则随机
|
||||
var cu = currentUser.Get();
|
||||
int orgId;
|
||||
if (cu?.OrgId is { } oid && await db.Organizations.AnyAsync(o => o.Id == oid))
|
||||
orgId = oid;
|
||||
else
|
||||
{
|
||||
var orgIds = await db.Organizations.Select(o => o.Id).ToListAsync();
|
||||
if (orgIds.Count == 0)
|
||||
return BadRequest(new { message = "暂无组织数据,请先在「组织管理」中创建组织" });
|
||||
orgId = orgIds[rnd.Next(orgIds.Count)];
|
||||
}
|
||||
|
||||
// 4. 随机生成 20-60 条
|
||||
var count = rnd.Next(20, 61);
|
||||
var baseSeq = await db.PurchaseOrders.CountAsync(x =>
|
||||
x.CreatedAt >= todayStart && x.CreatedAt < todayStart.AddDays(1));
|
||||
|
||||
var grades = new[] { "双宫", "黄斑", "血茧" };
|
||||
var orders = new List<PurchaseOrder>();
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
// 等级:95% 正茧,其余在双宫/黄斑/血茧中随机
|
||||
var grade = rnd.Next(100) < 95 ? "正茧" : grades[rnd.Next(grades.Length)];
|
||||
|
||||
// 单价(元/公斤)按等级定价
|
||||
var unitPrice = grade switch
|
||||
{
|
||||
"正茧" => rnd.Next(2800, 3601) / 100m, // 28.00 - 36.00
|
||||
"双宫" => rnd.Next(1800, 2401) / 100m, // 18.00 - 24.00
|
||||
"黄斑" => rnd.Next(1000, 1501) / 100m, // 10.00 - 15.00
|
||||
_ => rnd.Next(500, 901) / 100m // 血茧 5.00 - 9.00
|
||||
};
|
||||
|
||||
// 重量:净重 15-150kg,皮重 3-9kg
|
||||
var net = rnd.Next(1500, 15000) / 100m;
|
||||
var tare = rnd.Next(300, 900) / 100m;
|
||||
|
||||
// 时间:今日 06:00-22:00 随机进场
|
||||
var weighIn = todayStart.AddHours(rnd.Next(6, 22)).AddMinutes(rnd.Next(0, 60));
|
||||
var weighOut = weighIn.AddMinutes(rnd.Next(5, 25));
|
||||
|
||||
orders.Add(new PurchaseOrder
|
||||
{
|
||||
OrderNo = $"CG{todayStart:yyyyMMdd}-{baseSeq + i + 1:D4}",
|
||||
FarmerId = farmerIds[rnd.Next(farmerIds.Count)],
|
||||
ProductId = cocoon.Id,
|
||||
PurchaserOrgId = orgId,
|
||||
Grade = grade,
|
||||
Unit = "公斤",
|
||||
UnitPrice = unitPrice,
|
||||
GrossWeight = net + tare,
|
||||
TareWeight = tare,
|
||||
NetWeight = net,
|
||||
Amount = Math.Round(net * unitPrice, 2),
|
||||
WeighCount = 2,
|
||||
Status = PurchaseStatus.Completed,
|
||||
OperatorId = cu?.Id,
|
||||
WeighInAt = weighIn,
|
||||
WeighOutAt = weighOut,
|
||||
CreatedAt = weighIn,
|
||||
UpdatedAt = weighOut,
|
||||
Notes = "演示数据"
|
||||
});
|
||||
}
|
||||
|
||||
db.PurchaseOrders.AddRange(orders);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new { created = orders.Count, from = todayStart, to = now });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
/// <summary>登录</summary>
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<LoginResult>> Login(LoginRequest req)
|
||||
{
|
||||
var user = await db.Users.Include(u => u.Org)
|
||||
.FirstOrDefaultAsync(u => u.Username == req.Username);
|
||||
if (user is null || !BCrypt.Net.BCrypt.Verify(req.Password, user.PasswordHash))
|
||||
return Unauthorized(new { message = "用户名或密码错误" });
|
||||
if (!user.IsActive)
|
||||
return Unauthorized(new { message = "账号已被停用,请联系管理员" });
|
||||
|
||||
user.LastLoginAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var token = jwt.Generate(user);
|
||||
return Ok(new LoginResult(token, BuildInfo(user)));
|
||||
}
|
||||
|
||||
/// <summary>当前登录用户信息</summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<UserInfoDto>> Me()
|
||||
{
|
||||
var cu = currentUser.Get()!;
|
||||
var user = await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == cu.Id);
|
||||
return Ok(BuildInfo(user));
|
||||
}
|
||||
|
||||
/// <summary>修改密码</summary>
|
||||
[HttpPut("password")]
|
||||
[Authorize]
|
||||
public async Task<IActionResult> ChangePassword(ChangePasswordRequest req)
|
||||
{
|
||||
var cu = currentUser.Get()!;
|
||||
var user = await db.Users.FindAsync(cu.Id);
|
||||
if (user is null) return NotFound();
|
||||
if (!BCrypt.Net.BCrypt.Verify(req.OldPassword, user.PasswordHash))
|
||||
return BadRequest(new { message = "原密码不正确" });
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.NewPassword);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "密码修改成功" });
|
||||
}
|
||||
|
||||
private static UserInfoDto BuildInfo(User user) => new(
|
||||
user.Id, user.Username, user.RealName, user.Phone, user.Role.ToString(),
|
||||
user.OrgId, user.Org?.Name, user.Org?.Type.ToString(), user.IsActive);
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/dashboard")]
|
||||
[Authorize]
|
||||
public class DashboardController(
|
||||
AppDbContext db, DataScopeService scope, CurrentUserService currentUser,
|
||||
IHttpClientFactory httpFactory) : ControllerBase
|
||||
{
|
||||
private async Task<IQueryable<PurchaseOrder>> ScopedOrdersAsync()
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
return query;
|
||||
}
|
||||
|
||||
/// <summary>解析查询区间:[From, ToExclusive)。未传 from 时默认回溯 days 天。</summary>
|
||||
private static (DateTime From, DateTime ToExclusive) ResolveRange(DateTime? from, DateTime? to, int days = 30)
|
||||
{
|
||||
var end = to?.Date ?? DateTime.Today;
|
||||
var start = from?.Date ?? end.AddDays(-(days - 1));
|
||||
return (start, end.AddDays(1));
|
||||
}
|
||||
|
||||
/// <summary>大屏概览 KPI(按日期区间)</summary>
|
||||
[HttpGet("overview")]
|
||||
public async Task<ActionResult<OverviewDto>> Overview(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var completed = orders.Where(p => p.Status == PurchaseStatus.Completed
|
||||
&& p.CreatedAt >= start && p.CreatedAt < endEx);
|
||||
|
||||
var amount = await completed.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var count = await orders.CountAsync(p => p.CreatedAt >= start && p.CreatedAt < endEx);
|
||||
var netWeight = await completed.SumAsync(p => (decimal?)p.NetWeight) ?? 0;
|
||||
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var payQuery = db.PaymentRecords.Where(p => p.Status == PayStatus.Success).AsQueryable();
|
||||
if (visible is not null)
|
||||
payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
var paid = await payQuery
|
||||
.Where(p => p.PaidAt >= start && p.PaidAt < endEx)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var avgPrice = netWeight > 0 ? Math.Round(amount / netWeight, 2) : 0;
|
||||
var farmerCount = await completed.Select(p => p.FarmerId).Distinct().CountAsync();
|
||||
var weighingCount = await orders.CountAsync(p => p.Status == PurchaseStatus.Weighing);
|
||||
|
||||
return Ok(new OverviewDto(amount, count, netWeight, paid, avgPrice, farmerCount, weighingCount));
|
||||
}
|
||||
|
||||
/// <summary>区间收购金额/重量趋势(逐日)</summary>
|
||||
[HttpGet("trend")]
|
||||
public async Task<ActionResult<TrendPoint[]>> Trend(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 14);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.GroupBy(p => new { p.CreatedAt.Year, p.CreatedAt.Month, p.CreatedAt.Day })
|
||||
.Select(g => new { Key = new DateTime(g.Key.Year, g.Key.Month, g.Key.Day), Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight) })
|
||||
.ToListAsync();
|
||||
var map = rows.ToDictionary(r => r.Key, r => r);
|
||||
|
||||
var days = (int)(endEx - start).TotalDays;
|
||||
var result = Enumerable.Range(0, days).Select(i =>
|
||||
{
|
||||
var date = start.AddDays(i);
|
||||
map.TryGetValue(date, out var row);
|
||||
return new TrendPoint(date.ToString("MM-dd"), row?.Amount ?? 0, row?.Weight ?? 0);
|
||||
}).ToArray();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>品种收购金额占比</summary>
|
||||
[HttpGet("product-distribution")]
|
||||
public async Task<ActionResult<RatioPoint[]>> ProductDistribution(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.Product!.Name, p.Amount })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.Name)
|
||||
.Select(g => new RatioPoint(g.Key, g.Sum(p => p.Amount)))
|
||||
.OrderByDescending(r => r.Value)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>农户收购金额排行</summary>
|
||||
[HttpGet("farmer-top")]
|
||||
public async Task<ActionResult<FarmerTopPoint[]>> FarmerTop(DateTime? from, DateTime? to, int limit = 8)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.FarmerId, Name = p.Farmer!.Name, p.Amount, p.NetWeight })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => new { p.FarmerId, p.Name })
|
||||
.Select(g => new FarmerTopPoint(
|
||||
g.Key.FarmerId, g.Key.Name,
|
||||
g.Sum(p => p.Amount), g.Count(), g.Sum(p => p.NetWeight)))
|
||||
.OrderByDescending(r => r.Amount).Take(limit)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>收购方(公司/站/个体)对比</summary>
|
||||
[HttpGet("station-comparison")]
|
||||
public async Task<ActionResult<StationPoint[]>> StationComparison(DateTime? from, DateTime? to)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.PurchaserOrgId, Name = p.PurchaserOrg!.Name, p.Amount, p.NetWeight })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => new { p.PurchaserOrgId, p.Name })
|
||||
.Select(g => new StationPoint(
|
||||
g.Key.PurchaserOrgId, g.Key.Name,
|
||||
g.Sum(p => p.Amount), g.Count(), g.Sum(p => p.NetWeight)))
|
||||
.OrderByDescending(r => r.Amount)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>收购单价排行(按品种平均单价)</summary>
|
||||
[HttpGet("price-top")]
|
||||
public async Task<ActionResult<PriceTopPoint[]>> PriceTop(DateTime? from, DateTime? to, int limit = 8)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { Name = p.Product!.Name, p.UnitPrice, p.NetWeight })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.Name)
|
||||
.Select(g => new PriceTopPoint(
|
||||
g.Key, Math.Round(g.Average(p => p.UnitPrice), 2),
|
||||
g.Sum(p => p.NetWeight), g.Count()))
|
||||
.OrderByDescending(r => r.AvgPrice).Take(limit)
|
||||
.ToArray();
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
/// <summary>农户收入排行(收入 = 区间收购金额,附已付金额)</summary>
|
||||
[HttpGet("income-top")]
|
||||
public async Task<ActionResult<IncomeTopPoint[]>> IncomeTop(DateTime? from, DateTime? to, int limit = 8)
|
||||
{
|
||||
var (start, endEx) = ResolveRange(from, to, 30);
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var farmerRows = (await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < endEx)
|
||||
.Select(p => new { p.FarmerId, Name = p.Farmer!.Name, p.Amount })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.FarmerId)
|
||||
.Select(g => new { FarmerId = g.Key, Name = g.First().Name, Amount = g.Sum(p => p.Amount), Count = g.Count() })
|
||||
.ToList();
|
||||
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var payQuery = db.PaymentRecords
|
||||
.Where(p => p.Status == PayStatus.Success && p.PaidAt >= start && p.PaidAt < endEx).AsQueryable();
|
||||
if (visible is not null)
|
||||
payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
var paidMap = (await payQuery
|
||||
.Select(p => new { p.FarmerId, p.Amount })
|
||||
.ToListAsync())
|
||||
.GroupBy(p => p.FarmerId)
|
||||
.ToDictionary(g => g.Key, g => g.Sum(p => p.Amount));
|
||||
|
||||
var result = farmerRows
|
||||
.Select(r => new IncomeTopPoint(r.FarmerId, r.Name, r.Amount, paidMap.GetValueOrDefault(r.FarmerId), r.Count))
|
||||
.OrderByDescending(r => r.Amount).Take(limit)
|
||||
.ToArray();
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>实时过磅(进行中的收购单)</summary>
|
||||
[HttpGet("realtime-weighing")]
|
||||
public async Task<ActionResult<RealtimeWeighing[]>> RealtimeWeighing()
|
||||
{
|
||||
var orders = await ScopedOrdersAsync();
|
||||
var rows = await orders
|
||||
.Where(p => p.Status == PurchaseStatus.Weighing)
|
||||
.OrderByDescending(p => p.WeighInAt).Take(20)
|
||||
.Select(p => new RealtimeWeighing(
|
||||
p.Id, p.OrderNo, p.Farmer!.Name, p.Product!.Name,
|
||||
p.Status.ToString(), p.GrossWeight, p.WeighInAt))
|
||||
.ToListAsync();
|
||||
return Ok(rows.ToArray());
|
||||
}
|
||||
|
||||
// ---------- 天气(ShowAPI ip-to-weather:IP 定位实时天气) ----------
|
||||
|
||||
/// <summary>ShowAPI 天气中文描述 -> 图标</summary>
|
||||
private static string IconFor(string text)
|
||||
{
|
||||
if (string.IsNullOrEmpty(text)) return "🌡️";
|
||||
if (text.Contains("晴")) return "☀️";
|
||||
if (text.Contains("雷") || text.Contains("电")) return "⛈️";
|
||||
if (text.Contains("雨")) return "🌧️";
|
||||
if (text.Contains("雪")) return "🌨️";
|
||||
if (text.Contains("多云") || text.Contains("少云") || text.Contains("转")) return "⛅";
|
||||
if (text.Contains("阴")) return "☁️";
|
||||
if (text.Contains("雾") || text.Contains("霾")) return "🌫️";
|
||||
if (text.Contains("风")) return "🌪️";
|
||||
if (text.Contains("霜")) return "🥶";
|
||||
return "🌡️";
|
||||
}
|
||||
|
||||
private static (DateTime Time, WeatherInfo? Data) _weatherCache;
|
||||
|
||||
/// <summary>天气预报:ShowAPI ip-to-weather(按调用方 IP 定位,返回实时天气)</summary>
|
||||
[HttpGet("weather")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<WeatherInfo>> Weather()
|
||||
{
|
||||
if (_weatherCache.Data is not null && DateTime.UtcNow - _weatherCache.Time < TimeSpan.FromMinutes(30))
|
||||
return Ok(_weatherCache.Data);
|
||||
|
||||
var client = httpFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
try
|
||||
{
|
||||
var ip = await ResolveClientIpAsync(client);
|
||||
|
||||
// ShowAPI ip-to-weather:必须携带 ip 参数,Authorization 头带 AppCode
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get,
|
||||
$"https://ali-weather.showapi.com/ip-to-weather?ip={Uri.EscapeDataString(ip)}");
|
||||
request.Headers.TryAddWithoutValidation("Authorization", "APPCODE e3bdae1a73d2415492e7fdc024f07d08");
|
||||
using var resp = await client.SendAsync(request);
|
||||
resp.EnsureSuccessStatusCode();
|
||||
using var doc = JsonDocument.Parse(await resp.Content.ReadAsStringAsync());
|
||||
|
||||
var root = doc.RootElement;
|
||||
if (root.TryGetProperty("showapi_res_code", out var rc) && rc.GetInt32() != 0)
|
||||
return StatusCode(502, new
|
||||
{
|
||||
message = "天气服务暂不可用",
|
||||
detail = root.TryGetProperty("showapi_res_error", out var re) ? re.GetString() : "未知错误"
|
||||
});
|
||||
|
||||
var body = root.GetProperty("showapi_res_body");
|
||||
var cityInfo = body.GetProperty("cityInfo");
|
||||
var now = body.GetProperty("now");
|
||||
var today = body.TryGetProperty("f1", out var f1) ? f1 : default;
|
||||
|
||||
var city = cityInfo.TryGetProperty("c3", out var c) ? c.GetString() ?? "" : "";
|
||||
var region = cityInfo.TryGetProperty("c7", out var p) ? p.GetString() ?? "" : "";
|
||||
|
||||
var text = now.TryGetProperty("weather", out var w) ? w.GetString() ?? "" : "";
|
||||
if (string.IsNullOrEmpty(text)) text = "未知";
|
||||
|
||||
var temp = ParseWeatherNumber(now, "temperature");
|
||||
var feels = ParseWeatherNumber(now, "feels_like");
|
||||
var humidity = ParseWeatherNumber(now, "sd");
|
||||
var wind = $"{GetWeatherField(now, "wind_direction")} {GetWeatherField(now, "wind_power")}".Trim();
|
||||
|
||||
var min = today.ValueKind == JsonValueKind.Object ? ParseWeatherNumber(today, "night_air_temperature") : temp;
|
||||
var max = today.ValueKind == JsonValueKind.Object ? ParseWeatherNumber(today, "day_air_temperature") : temp;
|
||||
|
||||
var result = new WeatherInfo(
|
||||
city, region, text, IconFor(text),
|
||||
temp, feels, humidity, wind,
|
||||
min, max, DateTime.Now, "ShowAPI");
|
||||
|
||||
_weatherCache = (DateTime.UtcNow, result);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(502, new { message = "天气服务暂不可用", detail = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>解析调用方 IP:代理转发头 -> 直连 IP -> 本机公网 IP 兜底(本地开发时用)</summary>
|
||||
private async Task<string> ResolveClientIpAsync(HttpClient client)
|
||||
{
|
||||
var fwd = Request.Headers["X-Forwarded-For"].FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(fwd))
|
||||
{
|
||||
var ip = fwd.Split(',')[0].Trim();
|
||||
if (!IsPrivateIp(ip)) return ip;
|
||||
}
|
||||
var remote = HttpContext.Connection.RemoteIpAddress?.MapToIPv4().ToString() ?? "";
|
||||
if (!IsPrivateIp(remote)) return remote;
|
||||
|
||||
try { return (await client.GetStringAsync("http://ip.3322.net")).Trim(); }
|
||||
catch { return remote.Length > 0 ? remote : "0.0.0.0"; }
|
||||
}
|
||||
|
||||
private static bool IsPrivateIp(string ip)
|
||||
{
|
||||
if (string.IsNullOrEmpty(ip) || ip == "::1") return true;
|
||||
if (!IPAddress.TryParse(ip, out var addr)) return true;
|
||||
var bytes = addr.GetAddressBytes();
|
||||
if (bytes.Length != 4) return true; // 仅处理 IPv4
|
||||
return bytes[0] == 10
|
||||
|| bytes[0] == 127
|
||||
|| (bytes[0] == 192 && bytes[1] == 168)
|
||||
|| (bytes[0] == 172 && bytes[1] is >= 16 and <= 31)
|
||||
|| bytes[0] == 0;
|
||||
}
|
||||
|
||||
/// <summary>读取数值型天气字段(容忍"25℃"、"50%"等后缀)</summary>
|
||||
private static decimal ParseWeatherNumber(JsonElement obj, string prop)
|
||||
{
|
||||
if (!obj.TryGetProperty(prop, out var el)) return 0;
|
||||
var raw = el.ValueKind == JsonValueKind.String ? el.GetString() : el.ToString();
|
||||
if (string.IsNullOrEmpty(raw)) return 0;
|
||||
var num = new string(raw.TakeWhile(ch => char.IsDigit(ch) || ch == '-' || ch == '.').ToArray());
|
||||
return decimal.TryParse(num, out var v) ? v : 0;
|
||||
}
|
||||
|
||||
/// <summary>读取天气字符串字段</summary>
|
||||
private static string GetWeatherField(JsonElement obj, string prop)
|
||||
{
|
||||
if (obj.TryGetProperty(prop, out var el) && el.ValueKind == JsonValueKind.String)
|
||||
return el.GetString() ?? "";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/farmers")]
|
||||
[Authorize]
|
||||
public class FarmersController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>农户列表(分页/关键字/地区筛选)</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<FarmerDto>>> List(
|
||||
string? status, string? province, string? county, string? township, string? village,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
var query = db.Farmers.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<FarmerStatus>(status, out var fs))
|
||||
query = query.Where(f => f.Status == fs);
|
||||
if (!string.IsNullOrWhiteSpace(province))
|
||||
query = query.Where(f => f.Province == province);
|
||||
if (!string.IsNullOrWhiteSpace(county))
|
||||
query = query.Where(f => f.County == county);
|
||||
if (!string.IsNullOrWhiteSpace(township))
|
||||
query = query.Where(f => f.Township == township);
|
||||
if (!string.IsNullOrWhiteSpace(village))
|
||||
query = query.Where(f => f.Village == village);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(f => f.Name.Contains(keyword)
|
||||
|| f.Phone.Contains(keyword)
|
||||
|| f.IdCard.Contains(keyword)
|
||||
|| f.Village.Contains(keyword)
|
||||
|| f.Province.Contains(keyword)
|
||||
|| f.County.Contains(keyword)
|
||||
|| f.Township.Contains(keyword)
|
||||
|| f.GroupName.Contains(keyword));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(f => f.Id)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.ToListAsync();
|
||||
return Ok(new PagedResult<FarmerDto>(items.ToDtos(), total));
|
||||
}
|
||||
|
||||
/// <summary>全部农户(下拉选择用)</summary>
|
||||
[HttpGet("all")]
|
||||
public async Task<ActionResult> All(string? keyword, int limit = 50)
|
||||
{
|
||||
var query = db.Farmers.Where(f => f.Status == FarmerStatus.Active).AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(f => f.Name.Contains(keyword) || f.Phone.Contains(keyword) || f.IdCard.Contains(keyword));
|
||||
var items = await query.OrderByDescending(f => f.Id).Take(limit)
|
||||
.Select(f => new { f.Id, f.Name, f.Phone, f.Province, f.County, f.Township, f.Village, f.GroupName, f.IdCard }).ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
/// <summary>农户详情(含交易统计)</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult> Get(int id)
|
||||
{
|
||||
var farmer = await db.Farmers.FindAsync(id);
|
||||
if (farmer is null) return NotFound();
|
||||
|
||||
var totalAmount = await db.PurchaseOrders
|
||||
.Where(p => p.FarmerId == id && p.Status == PurchaseStatus.Completed)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var orderCount = await db.PurchaseOrders
|
||||
.CountAsync(p => p.FarmerId == id && p.Status == PurchaseStatus.Completed);
|
||||
var paidAmount = await db.PaymentRecords
|
||||
.Where(p => p.FarmerId == id && p.Status == PayStatus.Success)
|
||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var dto = farmer.ToDto();
|
||||
return Ok(new { farmer = dto, totalAmount, orderCount, paidAmount });
|
||||
}
|
||||
|
||||
/// <summary>新增农户</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<FarmerDto>> Create(FarmerSaveRequest req)
|
||||
{
|
||||
if (await db.Farmers.AnyAsync(f => f.IdCard == req.IdCard))
|
||||
return BadRequest(new { message = "该身份证号已存在" });
|
||||
if (!Enum.TryParse<FarmerStatus>(req.Status, out var status)) status = FarmerStatus.Active;
|
||||
|
||||
var farmer = new Farmer
|
||||
{
|
||||
Name = req.Name, IdCard = req.IdCard, Gender = req.Gender, FarmerType = req.FarmerType,
|
||||
Phone = req.Phone,
|
||||
Province = req.Province, County = req.County, Township = req.Township,
|
||||
Village = req.Village, GroupName = req.GroupName,
|
||||
Address = req.Address,
|
||||
BankName = req.BankName, BankAccount = req.BankAccount,
|
||||
IdCardFrontUrl = req.IdCardFrontUrl, IdCardBackUrl = req.IdCardBackUrl,
|
||||
AvatarUrl = req.AvatarUrl,
|
||||
CreditScore = req.CreditScore, Status = status, Notes = req.Notes
|
||||
};
|
||||
db.Farmers.Add(farmer);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(farmer.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>修改农户</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult<FarmerDto>> Update(int id, FarmerSaveRequest req)
|
||||
{
|
||||
var farmer = await db.Farmers.FindAsync(id);
|
||||
if (farmer is null) return NotFound();
|
||||
if (req.IdCard != farmer.IdCard && await db.Farmers.AnyAsync(f => f.IdCard == req.IdCard))
|
||||
return BadRequest(new { message = "该身份证号已存在" });
|
||||
|
||||
farmer.Name = req.Name; farmer.IdCard = req.IdCard; farmer.Gender = req.Gender;
|
||||
farmer.FarmerType = req.FarmerType;
|
||||
farmer.Phone = req.Phone;
|
||||
farmer.Province = req.Province; farmer.County = req.County;
|
||||
farmer.Township = req.Township; farmer.Village = req.Village;
|
||||
farmer.GroupName = req.GroupName;
|
||||
farmer.Address = req.Address;
|
||||
farmer.BankName = req.BankName; farmer.BankAccount = req.BankAccount;
|
||||
farmer.IdCardFrontUrl = req.IdCardFrontUrl; farmer.IdCardBackUrl = req.IdCardBackUrl;
|
||||
farmer.AvatarUrl = req.AvatarUrl;
|
||||
farmer.CreditScore = req.CreditScore; farmer.Notes = req.Notes;
|
||||
if (Enum.TryParse<FarmerStatus>(req.Status, out var status)) farmer.Status = status;
|
||||
farmer.UpdatedAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(farmer.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>删除农户(存在收购记录时禁止删除,仅冻结)</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var farmer = await db.Farmers.FindAsync(id);
|
||||
if (farmer is null) return NotFound();
|
||||
if (await db.PurchaseOrders.AnyAsync(p => p.FarmerId == id))
|
||||
{
|
||||
farmer.Status = FarmerStatus.Frozen;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "该农户已有收购记录,已将其状态置为冻结" });
|
||||
}
|
||||
db.Farmers.Remove(farmer);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/invoices")]
|
||||
[Authorize]
|
||||
public class InvoicesController(
|
||||
AppDbContext db,
|
||||
DataScopeService scope,
|
||||
CurrentUserService currentUser,
|
||||
NumberGenerator numberGen) : ControllerBase
|
||||
{
|
||||
/// <summary>发票列表</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<InvoiceDto>>> List(
|
||||
string? status, int? farmerId, int? productId, DateTime? from, DateTime? to,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.Invoices
|
||||
.Include(i => i.Farmer).Include(i => i.PurchaserOrg)
|
||||
.Include(i => i.Product).Include(i => i.PurchaseOrder).Include(i => i.Operator)
|
||||
.AsQueryable();
|
||||
|
||||
if (visible is not null)
|
||||
query = query.Where(i => visible.Contains(i.PurchaserOrgId));
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<InvoiceStatus>(status, out var st))
|
||||
query = query.Where(i => i.Status == st);
|
||||
if (farmerId.HasValue) query = query.Where(i => i.FarmerId == farmerId);
|
||||
if (productId.HasValue) query = query.Where(i => i.ProductId == productId);
|
||||
if (from.HasValue) query = query.Where(i => i.IssueDate >= from);
|
||||
if (to.HasValue) query = query.Where(i => i.IssueDate < to.Value.Date.AddDays(1));
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(i => i.InvoiceNo.Contains(keyword)
|
||||
|| (i.Farmer != null && i.Farmer.Name.Contains(keyword))
|
||||
|| (i.Farmer != null && i.Farmer.IdCard.Contains(keyword)));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(i => i.IssueDate)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.ToListAsync();
|
||||
return Ok(new PagedResult<InvoiceDto>(items.ToDtos(), total));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 开票状态统计:以「过磅称重有效收购单」为口径。
|
||||
/// 待开票 + 开票成功 + 开票失败 + 开票异常 = 过磅称重有效收购单总数。
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult> Stats()
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed)
|
||||
.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var issued = await query.CountAsync(p =>
|
||||
db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued));
|
||||
var failed = await query.CountAsync(p =>
|
||||
!db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued)
|
||||
&& db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Failed));
|
||||
var abnormal = await query.CountAsync(p =>
|
||||
!db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued)
|
||||
&& !db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Failed)
|
||||
&& db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Abnormal));
|
||||
var pending = total - issued - failed - abnormal;
|
||||
|
||||
return Ok(new { total, pending, issued, failed, abnormal });
|
||||
}
|
||||
|
||||
/// <summary>开票状态优先级(用于收购单多张发票时取最终状态)</summary>
|
||||
private static int StatusRank(InvoiceStatus s) => s switch
|
||||
{
|
||||
InvoiceStatus.Issued => 4,
|
||||
InvoiceStatus.Failed => 3,
|
||||
InvoiceStatus.Abnormal => 2,
|
||||
InvoiceStatus.Pending => 1,
|
||||
_ => 0
|
||||
};
|
||||
|
||||
/// <summary>收购单维度开票清单(反向开票数据源:过磅称重有效收购单)</summary>
|
||||
/// <param name="status">None/Pending/Issued/Failed/Abnormal,空为全部</param>
|
||||
[HttpGet("orders")]
|
||||
public async Task<ActionResult<PagedResult<OrderInvoiceDto>>> OrderList(
|
||||
string? status, DateTime? from, DateTime? to,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product).Include(p => p.PurchaserOrg)
|
||||
.Where(p => p.Status == PurchaseStatus.Completed)
|
||||
.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
if (from.HasValue) query = query.Where(p => p.WeighOutAt >= from);
|
||||
if (to.HasValue) query = query.Where(p => p.WeighOutAt < to.Value.Date.AddDays(1));
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(p => p.OrderNo.Contains(keyword)
|
||||
|| (p.Farmer != null && p.Farmer.Name.Contains(keyword))
|
||||
|| (p.Farmer != null && p.Farmer.IdCard.Contains(keyword)));
|
||||
|
||||
var all = await query.OrderByDescending(p => p.WeighOutAt).ToListAsync();
|
||||
|
||||
// 非作废发票按收购单聚合,取优先级最高的那条
|
||||
var orderIds = all.Select(p => p.Id).ToArray();
|
||||
var invoices = await db.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Reversed && i.PurchaseOrderId != null && orderIds.Contains(i.PurchaseOrderId.Value))
|
||||
.ToListAsync();
|
||||
var invMap = invoices
|
||||
.Where(i => i.PurchaseOrderId.HasValue)
|
||||
.GroupBy(i => i.PurchaseOrderId!.Value)
|
||||
.ToDictionary(g => g.Key, g => g.OrderByDescending(x => StatusRank(x.Status)).First());
|
||||
|
||||
var mapped = all.Select(p =>
|
||||
{
|
||||
invMap.TryGetValue(p.Id, out var inv);
|
||||
var invStatus = inv is null ? "None" : inv.Status.ToString();
|
||||
return new OrderInvoiceDto(
|
||||
p.Id, p.OrderNo, p.WeighOutAt,
|
||||
p.FarmerId, p.Farmer?.Name ?? "", p.Farmer?.IdCard ?? "",
|
||||
p.ProductId, p.Product?.Name ?? "", p.Unit, p.NetWeight, p.UnitPrice, p.Amount,
|
||||
p.PurchaserOrgId, p.PurchaserOrg?.Name ?? "", p.PurchaserOrg?.TaxNo,
|
||||
inv?.Id, inv?.InvoiceNo, string.IsNullOrEmpty(inv?.BatchNo) ? null : inv.BatchNo,
|
||||
invStatus, inv?.IssueDate);
|
||||
});
|
||||
|
||||
// OPEN = 待开票(未开票 None 或 开票中 Pending)
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
mapped = status == "OPEN"
|
||||
? mapped.Where(x => x.InvoiceStatus is "None" or "Pending")
|
||||
: mapped.Where(x => x.InvoiceStatus == status);
|
||||
|
||||
var list = mapped.ToList();
|
||||
var total = list.Count;
|
||||
var items = list.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||
return Ok(new PagedResult<OrderInvoiceDto>(items, total));
|
||||
}
|
||||
|
||||
/// <summary>可开票的已完成收购单(按农户)</summary>
|
||||
[HttpGet("available-orders")]
|
||||
public async Task<ActionResult> AvailableOrders(int farmerId)
|
||||
{
|
||||
var orders = await db.PurchaseOrders
|
||||
.Where(p => p.FarmerId == farmerId && p.Status == PurchaseStatus.Completed)
|
||||
.Where(p => !db.Invoices.Any(i => i.PurchaseOrderId == p.Id && i.Status == InvoiceStatus.Issued))
|
||||
.Include(p => p.Product)
|
||||
.Select(p => new
|
||||
{
|
||||
p.Id, p.OrderNo, ProductName = p.Product!.Name,
|
||||
p.ProductId, p.NetWeight, p.Unit, p.UnitPrice, p.Amount, p.WeighOutAt
|
||||
})
|
||||
.ToListAsync();
|
||||
return Ok(orders);
|
||||
}
|
||||
|
||||
/// <summary>开具收购发票(反向开票:收购方向农户开具)</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<InvoiceDto>> Create(InvoiceCreateRequest req)
|
||||
{
|
||||
if (!await db.Farmers.AnyAsync(f => f.Id == req.FarmerId))
|
||||
return BadRequest(new { message = "农户不存在" });
|
||||
if (!await db.Products.AnyAsync(p => p.Id == req.ProductId))
|
||||
return BadRequest(new { message = "品种不存在" });
|
||||
if (!await db.Organizations.AnyAsync(o => o.Id == req.PurchaserOrgId))
|
||||
return BadRequest(new { message = "收购方组织不存在" });
|
||||
|
||||
if (req.PurchaseOrderId.HasValue)
|
||||
{
|
||||
var order = await db.PurchaseOrders.FindAsync(req.PurchaseOrderId);
|
||||
if (order is null || order.FarmerId != req.FarmerId)
|
||||
return BadRequest(new { message = "收购单不存在或与农户不匹配" });
|
||||
if (await db.Invoices.AnyAsync(i => i.PurchaseOrderId == req.PurchaseOrderId && i.Status == InvoiceStatus.Issued))
|
||||
return BadRequest(new { message = "该收购单已开具发票" });
|
||||
}
|
||||
|
||||
var cu = currentUser.Get()!;
|
||||
var invoiceNo = await numberGen.NextAsync("FP");
|
||||
var taxAmount = Math.Round(req.Amount * req.TaxRate / 100m, 2);
|
||||
|
||||
var invoice = new Invoice
|
||||
{
|
||||
InvoiceNo = invoiceNo,
|
||||
InvoiceKind = req.InvoiceKind,
|
||||
FarmerId = req.FarmerId,
|
||||
PurchaserOrgId = req.PurchaserOrgId,
|
||||
PurchaseOrderId = req.PurchaseOrderId,
|
||||
ProductId = req.ProductId,
|
||||
Quantity = req.Quantity,
|
||||
Unit = req.Unit,
|
||||
UnitPrice = req.UnitPrice,
|
||||
Amount = req.Amount,
|
||||
TaxRate = req.TaxRate,
|
||||
TaxAmount = taxAmount,
|
||||
IssueDate = DateTime.Now,
|
||||
Status = InvoiceStatus.Pending,
|
||||
OperatorId = cu.Id
|
||||
};
|
||||
db.Invoices.Add(invoice);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.Invoices
|
||||
.Include(i => i.Farmer).Include(i => i.PurchaserOrg)
|
||||
.Include(i => i.Product).Include(i => i.PurchaseOrder).Include(i => i.Operator)
|
||||
.FirstAsync(i => i.Id == invoice.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>开票结果回填(模拟税控平台回调:Issued/Failed/Abnormal)</summary>
|
||||
[HttpPut("{id:int}/result")]
|
||||
public async Task<ActionResult<InvoiceDto>> UpdateResult(int id, InvoiceResultRequest req)
|
||||
{
|
||||
var invoice = await db.Invoices.FindAsync(id);
|
||||
if (invoice is null) return NotFound();
|
||||
if (invoice.Status == InvoiceStatus.Reversed)
|
||||
return BadRequest(new { message = "已作废发票不可更新状态" });
|
||||
if (!Enum.TryParse<InvoiceStatus>(req.Status, out var st) || st == InvoiceStatus.Reversed)
|
||||
return BadRequest(new { message = "开票结果状态不正确" });
|
||||
|
||||
invoice.Status = st;
|
||||
invoice.IssueDate = DateTime.Now;
|
||||
if (!string.IsNullOrWhiteSpace(req.Message))
|
||||
invoice.ReverseReason = req.Message;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.Invoices
|
||||
.Include(i => i.Farmer).Include(i => i.PurchaserOrg)
|
||||
.Include(i => i.Product).Include(i => i.PurchaseOrder).Include(i => i.Operator)
|
||||
.FirstAsync(i => i.Id == invoice.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>发票作废(红冲)</summary>
|
||||
[HttpPut("{id:int}/reverse")]
|
||||
public async Task<IActionResult> Reverse(int id, InvoiceReverseRequest req)
|
||||
{
|
||||
var invoice = await db.Invoices.FindAsync(id);
|
||||
if (invoice is null) return NotFound();
|
||||
if (invoice.Status != InvoiceStatus.Issued)
|
||||
return BadRequest(new { message = "该发票已作废" });
|
||||
|
||||
invoice.Status = InvoiceStatus.Reversed;
|
||||
invoice.ReversedAt = DateTime.Now;
|
||||
invoice.ReverseReason = req.Reason;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "作废成功" });
|
||||
}
|
||||
|
||||
/// <summary>创建开票批次并批量发起开票(基于过磅称重有效收购单,生成待开票 Pending 发票)</summary>
|
||||
[HttpPost("batches")]
|
||||
public async Task<ActionResult<BatchProgressDto>> CreateBatch(CreateBatchRequest req)
|
||||
{
|
||||
if (req.OrderIds is null || req.OrderIds.Length == 0)
|
||||
return BadRequest(new { message = "请选择要开票的收购单" });
|
||||
var cu = currentUser.Get()!;
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
|
||||
var orderIds = req.OrderIds.Distinct().ToArray();
|
||||
var orders = await db.PurchaseOrders
|
||||
.Where(p => orderIds.Contains(p.Id) && p.Status == PurchaseStatus.Completed)
|
||||
.ToListAsync();
|
||||
if (visible is not null)
|
||||
orders = orders.Where(p => visible.Contains(p.PurchaserOrgId)).ToList();
|
||||
|
||||
var invoicedIds = await db.Invoices
|
||||
.Where(i => i.Status != InvoiceStatus.Reversed && i.PurchaseOrderId != null && orderIds.Contains(i.PurchaseOrderId.Value))
|
||||
.Select(i => i.PurchaseOrderId!.Value)
|
||||
.ToListAsync();
|
||||
var validOrders = orders.Where(p => !invoicedIds.Contains(p.Id)).ToList();
|
||||
if (validOrders.Count == 0)
|
||||
return BadRequest(new { message = "所选收购单均已开票,无可开票单据" });
|
||||
|
||||
var batchNo = await numberGen.NextAsync("PK");
|
||||
foreach (var order in validOrders)
|
||||
{
|
||||
db.Invoices.Add(new Invoice
|
||||
{
|
||||
InvoiceNo = await numberGen.NextAsync("FP"),
|
||||
BatchNo = batchNo,
|
||||
InvoiceKind = "销售发票",
|
||||
FarmerId = order.FarmerId,
|
||||
PurchaserOrgId = order.PurchaserOrgId,
|
||||
PurchaseOrderId = order.Id,
|
||||
ProductId = order.ProductId,
|
||||
Quantity = order.NetWeight,
|
||||
Unit = order.Unit,
|
||||
UnitPrice = order.UnitPrice,
|
||||
Amount = order.Amount,
|
||||
TaxRate = 0,
|
||||
TaxAmount = 0,
|
||||
IssueDate = DateTime.Now,
|
||||
Status = InvoiceStatus.Pending,
|
||||
OperatorId = cu.Id
|
||||
});
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok(new BatchProgressDto(batchNo, validOrders.Count, validOrders.Count, 0, 0, 0, false));
|
||||
}
|
||||
|
||||
/// <summary>开票批次进度(开票中/成功/失败/异常)</summary>
|
||||
[HttpGet("batches/{batchNo}/progress")]
|
||||
public async Task<ActionResult<BatchProgressDto>> BatchProgress(string batchNo)
|
||||
{
|
||||
var query = db.Invoices.Where(i => i.BatchNo == batchNo);
|
||||
var total = await query.CountAsync();
|
||||
if (total == 0) return NotFound();
|
||||
var pending = await query.CountAsync(i => i.Status == InvoiceStatus.Pending);
|
||||
var issued = await query.CountAsync(i => i.Status == InvoiceStatus.Issued);
|
||||
var failed = await query.CountAsync(i => i.Status == InvoiceStatus.Failed);
|
||||
var abnormal = await query.CountAsync(i => i.Status == InvoiceStatus.Abnormal);
|
||||
return Ok(new BatchProgressDto(batchNo, total, pending, issued, failed, abnormal, pending == 0));
|
||||
}
|
||||
|
||||
/// <summary>模拟税控平台处理:每次推进批次中的部分待开票发票为成功/失败/异常</summary>
|
||||
[HttpPost("batches/{batchNo}/step")]
|
||||
public async Task<ActionResult<BatchProgressDto>> BatchStep(string batchNo)
|
||||
{
|
||||
var pendingInvoices = await db.Invoices
|
||||
.Where(i => i.BatchNo == batchNo && i.Status == InvoiceStatus.Pending)
|
||||
.OrderBy(i => i.Id)
|
||||
.Take(3)
|
||||
.ToListAsync();
|
||||
|
||||
var rnd = Random.Shared;
|
||||
foreach (var inv in pendingInvoices)
|
||||
{
|
||||
var r = rnd.Next(100);
|
||||
inv.Status = r < 70 ? InvoiceStatus.Issued
|
||||
: r < 85 ? InvoiceStatus.Failed
|
||||
: InvoiceStatus.Abnormal;
|
||||
if (inv.Status == InvoiceStatus.Failed)
|
||||
inv.ReverseReason = "开票平台处理失败(模拟):税控设备连接超时";
|
||||
else if (inv.Status == InvoiceStatus.Abnormal)
|
||||
inv.ReverseReason = "开票平台返回异常(模拟):数据校验未通过";
|
||||
inv.IssueDate = DateTime.Now;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return await BatchProgress(batchNo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/orgs")]
|
||||
[Authorize]
|
||||
public class OrgsController(AppDbContext db, DataScopeService scope, CurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
/// <summary>组织列表(按数据权限过滤)</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> List(string? type, string? keyword)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.Organizations.Include(o => o.Parent).AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(o => visible.Contains(o.Id));
|
||||
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<OrgType>(type, out var orgType))
|
||||
query = query.Where(o => o.Type == orgType);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(o => o.Name.Contains(keyword) || o.ContactPerson.Contains(keyword) || o.Phone.Contains(keyword));
|
||||
|
||||
var items = await query.OrderBy(o => o.Type).ThenBy(o => o.Id).ToListAsync();
|
||||
return Ok(items.ToDtos());
|
||||
}
|
||||
|
||||
/// <summary>组织详情</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult<OrgDto>> Get(int id)
|
||||
{
|
||||
var org = await db.Organizations.Include(o => o.Parent).FirstOrDefaultAsync(o => o.Id == id);
|
||||
if (org is null) return NotFound();
|
||||
return Ok(org.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>新增组织</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<OrgDto>> Create(OrgSaveRequest req)
|
||||
{
|
||||
if (!Enum.TryParse<OrgType>(req.Type, out var orgType))
|
||||
return BadRequest(new { message = "组织类型不正确" });
|
||||
|
||||
var org = new Organization
|
||||
{
|
||||
Type = orgType,
|
||||
Name = req.Name,
|
||||
ParentId = req.ParentId,
|
||||
ContactPerson = req.ContactPerson,
|
||||
Phone = req.Phone,
|
||||
Address = req.Address,
|
||||
TaxNo = req.TaxNo,
|
||||
IsActive = req.IsActive
|
||||
};
|
||||
db.Organizations.Add(org);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok((await db.Organizations.Include(o => o.Parent).FirstAsync(o => o.Id == org.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>修改组织</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult<OrgDto>> Update(int id, OrgSaveRequest req)
|
||||
{
|
||||
var org = await db.Organizations.FindAsync(id);
|
||||
if (org is null) return NotFound();
|
||||
if (Enum.TryParse<OrgType>(req.Type, out var orgType)) org.Type = orgType;
|
||||
org.Name = req.Name;
|
||||
org.ParentId = req.ParentId;
|
||||
org.ContactPerson = req.ContactPerson;
|
||||
org.Phone = req.Phone;
|
||||
org.Address = req.Address;
|
||||
org.TaxNo = req.TaxNo;
|
||||
org.IsActive = req.IsActive;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok((await db.Organizations.Include(o => o.Parent).FirstAsync(o => o.Id == org.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>删除组织(存在关联时禁止删除)</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var org = await db.Organizations.FindAsync(id);
|
||||
if (org is null) return NotFound();
|
||||
if (await db.Organizations.AnyAsync(o => o.ParentId == id))
|
||||
return BadRequest(new { message = "该组织下存在下级组织,无法删除" });
|
||||
if (await db.PurchaseOrders.AnyAsync(p => p.PurchaserOrgId == id))
|
||||
return BadRequest(new { message = "该组织已有收购业务,无法删除" });
|
||||
if (await db.Users.AnyAsync(u => u.OrgId == id))
|
||||
return BadRequest(new { message = "该组织下存在用户,无法删除" });
|
||||
|
||||
db.Organizations.Remove(org);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/payments")]
|
||||
[Authorize]
|
||||
public class PaymentsController(
|
||||
AppDbContext db,
|
||||
DataScopeService scope,
|
||||
CurrentUserService currentUser,
|
||||
NumberGenerator numberGen) : ControllerBase
|
||||
{
|
||||
/// <summary>付款记录列表</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<PaymentDto>>> List(
|
||||
string? status, int? farmerId, int? orgId, DateTime? from, DateTime? to,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PaymentRecords
|
||||
.Include(p => p.Farmer).Include(p => p.PurchaseOrder)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.AsQueryable();
|
||||
|
||||
if (visible is not null)
|
||||
query = query.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<PayStatus>(status, out var ps))
|
||||
query = query.Where(p => p.Status == ps);
|
||||
if (farmerId.HasValue) query = query.Where(p => p.FarmerId == farmerId);
|
||||
if (orgId.HasValue) query = query.Where(p => p.PurchaserOrgId == orgId);
|
||||
if (from.HasValue) query = query.Where(p => p.CreatedAt >= from);
|
||||
if (to.HasValue) query = query.Where(p => p.CreatedAt < to.Value.Date.AddDays(1));
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(p => p.PayNo.Contains(keyword)
|
||||
|| p.TradeNo.Contains(keyword)
|
||||
|| (p.Farmer != null && p.Farmer.Name.Contains(keyword)));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.ToListAsync();
|
||||
return Ok(new PagedResult<PaymentDto>(items.ToDtos(), total));
|
||||
}
|
||||
|
||||
/// <summary>支付状态统计(待支付/成功/失败/异常)</summary>
|
||||
[HttpGet("stats")]
|
||||
public async Task<ActionResult> Stats()
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PaymentRecords.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
return Ok(new
|
||||
{
|
||||
pending = await query.CountAsync(p => p.Status == PayStatus.Pending),
|
||||
success = await query.CountAsync(p => p.Status == PayStatus.Success),
|
||||
failed = await query.CountAsync(p => p.Status == PayStatus.Failed),
|
||||
abnormal = await query.CountAsync(p => p.Status == PayStatus.Abnormal)
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>农户应收/已付汇总(结算用)</summary>
|
||||
[HttpGet("farmer-summary")]
|
||||
public async Task<ActionResult> FarmerSummary(int farmerId, int? orgId)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var orderQuery = db.PurchaseOrders
|
||||
.Where(p => p.FarmerId == farmerId && p.Status == PurchaseStatus.Completed)
|
||||
.AsQueryable();
|
||||
if (visible is not null)
|
||||
orderQuery = orderQuery.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
if (orgId.HasValue)
|
||||
orderQuery = orderQuery.Where(p => p.PurchaserOrgId == orgId);
|
||||
|
||||
var totalAmount = await orderQuery.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
var payQuery = db.PaymentRecords
|
||||
.Where(p => p.FarmerId == farmerId && p.Status == PayStatus.Success)
|
||||
.AsQueryable();
|
||||
if (visible is not null)
|
||||
payQuery = payQuery.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
if (orgId.HasValue)
|
||||
payQuery = payQuery.Where(p => p.PurchaserOrgId == orgId);
|
||||
|
||||
var paidAmount = await payQuery.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
|
||||
// 未结算的收购单(可批量付款)
|
||||
var unpaidOrders = await db.PurchaseOrders
|
||||
.Where(p => p.FarmerId == farmerId && p.Status == PurchaseStatus.Completed)
|
||||
.Where(p => !db.PaymentRecords.Any(pay =>
|
||||
pay.PurchaseOrderId == p.Id && pay.Status == PayStatus.Success))
|
||||
.Include(p => p.Product)
|
||||
.Select(p => new { p.Id, p.OrderNo, p.Product!.Name, p.NetWeight, p.Amount, p.UnitPrice })
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
farmerId,
|
||||
totalAmount,
|
||||
paidAmount,
|
||||
unpaidAmount = Math.Round(totalAmount - paidAmount, 2),
|
||||
unpaidOrders
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>发起支付(按收购单或对农户批量结算)</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<PaymentDto>> Create(PaymentCreateRequest req)
|
||||
{
|
||||
if (!await db.Farmers.AnyAsync(f => f.Id == req.FarmerId))
|
||||
return BadRequest(new { message = "农户不存在" });
|
||||
if (!Enum.TryParse<PayMethod>(req.Method, out var method))
|
||||
return BadRequest(new { message = "支付方式不正确" });
|
||||
|
||||
// 按收购单付款:校验订单并防止重复支付
|
||||
if (req.PurchaseOrderId.HasValue)
|
||||
{
|
||||
var order = await db.PurchaseOrders.FindAsync(req.PurchaseOrderId);
|
||||
if (order is null || order.FarmerId != req.FarmerId)
|
||||
return BadRequest(new { message = "收购单不存在或与农户不匹配" });
|
||||
if (await db.PaymentRecords.AnyAsync(p =>
|
||||
p.PurchaseOrderId == req.PurchaseOrderId && p.Status == PayStatus.Success))
|
||||
return BadRequest(new { message = "该收购单已支付成功" });
|
||||
}
|
||||
|
||||
var cu = currentUser.Get()!;
|
||||
int? purchaserOrgId = req.PurchaseOrderId.HasValue
|
||||
? (await db.PurchaseOrders.FindAsync(req.PurchaseOrderId))?.PurchaserOrgId
|
||||
: cu.OrgId;
|
||||
|
||||
var payNo = await numberGen.NextAsync("ZF");
|
||||
var payment = new PaymentRecord
|
||||
{
|
||||
PayNo = payNo,
|
||||
PurchaseOrderId = req.PurchaseOrderId,
|
||||
PurchaserOrgId = purchaserOrgId,
|
||||
FarmerId = req.FarmerId,
|
||||
Amount = req.Amount,
|
||||
Method = method,
|
||||
Status = PayStatus.Pending,
|
||||
OperatorId = cu.Id,
|
||||
Notes = req.Notes
|
||||
};
|
||||
db.PaymentRecords.Add(payment);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.PaymentRecords
|
||||
.Include(p => p.Farmer).Include(p => p.PurchaseOrder)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.FirstAsync(p => p.Id == payment.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>确认支付结果(模拟收单回调,默认支付成功,可选失败/异常)</summary>
|
||||
[HttpPut("{id:int}/confirm")]
|
||||
public async Task<ActionResult<PaymentDto>> Confirm(int id, PaymentConfirmRequest req)
|
||||
{
|
||||
var payment = await db.PaymentRecords.FindAsync(id);
|
||||
if (payment is null) return NotFound();
|
||||
if (payment.Status != PayStatus.Pending)
|
||||
return BadRequest(new { message = "当前状态不可确认" });
|
||||
|
||||
// Status 缺省或非法时按成功处理;成功/失败/异常均可写入
|
||||
var result = Enum.TryParse<PayStatus>(req.Status, out var st)
|
||||
&& st is PayStatus.Success or PayStatus.Failed or PayStatus.Abnormal
|
||||
? st : PayStatus.Success;
|
||||
|
||||
payment.Status = result;
|
||||
if (result == PayStatus.Success)
|
||||
{
|
||||
payment.PaidAt = DateTime.Now;
|
||||
payment.TradeNo = string.IsNullOrWhiteSpace(req.TradeNo) ? payment.TradeNo : req.TradeNo;
|
||||
if (string.IsNullOrWhiteSpace(payment.TradeNo))
|
||||
payment.TradeNo = $"TRADE{DateTime.Now:yyyyMMddHHmmss}{Random.Shared.Next(100, 999)}";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(req.Message))
|
||||
payment.Notes = string.IsNullOrWhiteSpace(payment.Notes) ? req.Message : $"{payment.Notes}\n{req.Message}";
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.PaymentRecords
|
||||
.Include(p => p.Farmer).Include(p => p.PurchaseOrder)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.FirstAsync(p => p.Id == payment.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>退款</summary>
|
||||
[HttpPut("{id:int}/refund")]
|
||||
public async Task<IActionResult> Refund(int id)
|
||||
{
|
||||
var payment = await db.PaymentRecords.FindAsync(id);
|
||||
if (payment is null) return NotFound();
|
||||
if (payment.Status != PayStatus.Success)
|
||||
return BadRequest(new { message = "仅已支付成功的记录可退款" });
|
||||
|
||||
payment.Status = PayStatus.Refunded;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "退款成功" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/products")]
|
||||
[Authorize]
|
||||
public class ProductsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>品种列表(可选品类/关键字)</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult> List(string? category, string? keyword)
|
||||
{
|
||||
var query = db.Products.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(category))
|
||||
query = query.Where(p => p.Category == category);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(p => p.Name.Contains(keyword) || p.Category.Contains(keyword));
|
||||
var items = await query.OrderBy(p => p.Category).ThenBy(p => p.Name).ToListAsync();
|
||||
return Ok(items.ToDtos());
|
||||
}
|
||||
|
||||
/// <summary>品类列表(筛选用)</summary>
|
||||
[HttpGet("categories")]
|
||||
public async Task<ActionResult> Categories()
|
||||
{
|
||||
var items = await db.Products.Select(p => p.Category).Distinct().OrderBy(c => c).ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<ProductDto>> Create(ProductSaveRequest req)
|
||||
{
|
||||
if (await db.Products.AnyAsync(p => p.Name == req.Name))
|
||||
return BadRequest(new { message = "品种名称已存在" });
|
||||
var product = new Product
|
||||
{
|
||||
Name = req.Name, Category = req.Category, Unit = req.Unit,
|
||||
Spec = req.Spec, Price = req.Price
|
||||
};
|
||||
if (Enum.TryParse<ProductStatus>(req.Status, out var status)) product.Status = status;
|
||||
db.Products.Add(product);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(product.ToDto());
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult<ProductDto>> Update(int id, ProductSaveRequest req)
|
||||
{
|
||||
var product = await db.Products.FindAsync(id);
|
||||
if (product is null) return NotFound();
|
||||
if (req.Name != product.Name && await db.Products.AnyAsync(p => p.Name == req.Name))
|
||||
return BadRequest(new { message = "品种名称已存在" });
|
||||
|
||||
product.Name = req.Name; product.Category = req.Category; product.Unit = req.Unit;
|
||||
product.Spec = req.Spec; product.Price = req.Price;
|
||||
if (Enum.TryParse<ProductStatus>(req.Status, out var status)) product.Status = status;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(product.ToDto());
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var product = await db.Products.FindAsync(id);
|
||||
if (product is null) return NotFound();
|
||||
if (await db.PurchaseOrders.AnyAsync(p => p.ProductId == id))
|
||||
return BadRequest(new { message = "该品种已有收购记录,无法删除" });
|
||||
db.Products.Remove(product);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/purchases")]
|
||||
[Authorize]
|
||||
public class PurchasesController(
|
||||
AppDbContext db,
|
||||
DataScopeService scope,
|
||||
CurrentUserService currentUser,
|
||||
NumberGenerator numberGen) : ControllerBase
|
||||
{
|
||||
/// <summary>收购单列表(分页 + 多条件 + 数据权限)</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<PurchaseDto>>> List(
|
||||
string? status, int? farmerId, int? productId, int? orgId, DateTime? from, DateTime? to,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.AsQueryable();
|
||||
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<PurchaseStatus>(status, out var ps))
|
||||
query = query.Where(p => p.Status == ps);
|
||||
if (farmerId.HasValue) query = query.Where(p => p.FarmerId == farmerId);
|
||||
if (productId.HasValue) query = query.Where(p => p.ProductId == productId);
|
||||
if (orgId.HasValue) query = query.Where(p => p.PurchaserOrgId == orgId);
|
||||
if (from.HasValue) query = query.Where(p => p.CreatedAt >= from);
|
||||
if (to.HasValue) query = query.Where(p => p.CreatedAt < to.Value.Date.AddDays(1));
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(p => p.OrderNo.Contains(keyword)
|
||||
|| (p.Farmer != null && p.Farmer.Name.Contains(keyword))
|
||||
|| (p.Farmer != null && p.Farmer.Phone.Contains(keyword)));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(p => p.CreatedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.ToListAsync();
|
||||
return Ok(new PagedResult<PurchaseDto>(items.ToDtos(), total));
|
||||
}
|
||||
|
||||
/// <summary>进行中的过磅单(触摸屏/大屏实时展示)</summary>
|
||||
[HttpGet("weighing")]
|
||||
public async Task<ActionResult> Weighing()
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Where(p => p.Status == PurchaseStatus.Weighing)
|
||||
.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
|
||||
var items = await query.OrderByDescending(p => p.WeighInAt).Take(20).ToListAsync();
|
||||
return Ok(items.ToDtos());
|
||||
}
|
||||
|
||||
/// <summary>收购单详情</summary>
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult<PurchaseDto>> Get(int id)
|
||||
{
|
||||
var order = await db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (order is null) return NotFound();
|
||||
return Ok(order.ToDto());
|
||||
}
|
||||
|
||||
/// <summary>新建收购单(第一次过磅:记录毛重)</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<PurchaseDto>> Create(PurchaseCreateRequest req)
|
||||
{
|
||||
if (!await db.Farmers.AnyAsync(f => f.Id == req.FarmerId))
|
||||
return BadRequest(new { message = "农户不存在" });
|
||||
if (!await db.Products.AnyAsync(p => p.Id == req.ProductId))
|
||||
return BadRequest(new { message = "品种不存在" });
|
||||
if (!await db.Organizations.AnyAsync(o => o.Id == req.PurchaserOrgId))
|
||||
return BadRequest(new { message = "收购方组织不存在" });
|
||||
|
||||
var cu = currentUser.Get()!;
|
||||
var orderNo = await numberGen.NextAsync("CG");
|
||||
var order = new PurchaseOrder
|
||||
{
|
||||
OrderNo = orderNo,
|
||||
FarmerId = req.FarmerId,
|
||||
ProductId = req.ProductId,
|
||||
PurchaserOrgId = req.PurchaserOrgId,
|
||||
Grade = req.Grade,
|
||||
Unit = req.Unit,
|
||||
UnitPrice = req.UnitPrice,
|
||||
GrossWeight = req.GrossWeight,
|
||||
WeighCount = 1,
|
||||
Status = PurchaseStatus.Weighing,
|
||||
OperatorId = cu.Id,
|
||||
Notes = req.Notes
|
||||
};
|
||||
db.PurchaseOrders.Add(order);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.FirstAsync(p => p.Id == order.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>回皮完成(第二次过磅:记录皮重,计算净重与金额)</summary>
|
||||
[HttpPut("{id:int}/tare")]
|
||||
public async Task<ActionResult<PurchaseDto>> CompleteTare(int id, PurchaseTareRequest req)
|
||||
{
|
||||
var order = await db.PurchaseOrders.FindAsync(id);
|
||||
if (order is null) return NotFound();
|
||||
if (order.Status != PurchaseStatus.Weighing)
|
||||
return BadRequest(new { message = "该收购单当前状态不可回皮" });
|
||||
if (req.TareWeight >= order.GrossWeight)
|
||||
return BadRequest(new { message = "皮重不能大于等于毛重" });
|
||||
|
||||
order.TareWeight = req.TareWeight;
|
||||
order.NetWeight = Math.Round(order.GrossWeight - req.TareWeight, 2);
|
||||
order.Amount = Math.Round(order.NetWeight * order.UnitPrice, 2);
|
||||
order.WeighCount = 2;
|
||||
order.Status = PurchaseStatus.Completed;
|
||||
order.WeighOutAt = DateTime.Now;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.FirstAsync(p => p.Id == order.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>修改单价/等级(完成前可调整)</summary>
|
||||
[HttpPut("{id:int}/price")]
|
||||
public async Task<ActionResult<PurchaseDto>> UpdatePrice(int id, [FromBody] decimal unitPrice, string? grade = null)
|
||||
{
|
||||
var order = await db.PurchaseOrders.FindAsync(id);
|
||||
if (order is null) return NotFound();
|
||||
if (order.Status == PurchaseStatus.Cancelled)
|
||||
return BadRequest(new { message = "作废单据不可修改" });
|
||||
|
||||
order.UnitPrice = unitPrice;
|
||||
if (!string.IsNullOrWhiteSpace(grade)) order.Grade = grade;
|
||||
order.Amount = Math.Round(order.NetWeight * order.UnitPrice, 2);
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Ok((await db.PurchaseOrders
|
||||
.Include(p => p.Farmer).Include(p => p.Product)
|
||||
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
|
||||
.FirstAsync(p => p.Id == order.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>作废收购单</summary>
|
||||
[HttpPut("{id:int}/cancel")]
|
||||
public async Task<IActionResult> Cancel(int id)
|
||||
{
|
||||
var order = await db.PurchaseOrders.FindAsync(id);
|
||||
if (order is null) return NotFound();
|
||||
if (order.Status == PurchaseStatus.Cancelled)
|
||||
return BadRequest(new { message = "该单据已作废" });
|
||||
if (await db.PaymentRecords.AnyAsync(p => p.PurchaseOrderId == id && p.Status == PayStatus.Success))
|
||||
return BadRequest(new { message = "该单据已付款,请先退款后再作废" });
|
||||
|
||||
order.Status = PurchaseStatus.Cancelled;
|
||||
order.UpdatedAt = DateTime.Now;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "作废成功" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 行政区划接口:
|
||||
/// 省、市、县、乡镇来自 AreaCity 全国公开行政区划数据(Regions 表,深 0~3);
|
||||
/// 村、组来自农户档案表(Famers 表已录入数据,去重后作为下拉选项)。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/regions")]
|
||||
[Authorize]
|
||||
public class RegionsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
/// <summary>省(自治区/直辖市)列表,热门省份(热度值高)优先</summary>
|
||||
[HttpGet("provinces")]
|
||||
public async Task<ActionResult<string[]>> Provinces()
|
||||
{
|
||||
var items = await db.Regions
|
||||
.Where(r => r.Deep == 0)
|
||||
.OrderByDescending(r => r.Hot)
|
||||
.ThenBy(r => r.Id)
|
||||
.Select(r => r.ExtName)
|
||||
.ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按省获取县(区/县级市)列表,附带所属地级市(city)用于前端分组,
|
||||
/// used=true 表示数据库农户表中已录入过该区县(前端优先展示)。
|
||||
/// </summary>
|
||||
[HttpGet("counties")]
|
||||
public async Task<ActionResult> Counties(string? province)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(province)) return BadRequest(new { message = "请先选择省份" });
|
||||
var prov = await db.Regions.FirstOrDefaultAsync(r => r.Deep == 0 && r.ExtName == province);
|
||||
if (prov == null) return Ok(Array.Empty<object>());
|
||||
|
||||
var items = await (from c in db.Regions
|
||||
join city in db.Regions on c.Pid equals city.Id
|
||||
where city.Pid == prov.Id && c.Deep == 2
|
||||
orderby city.Id, c.Id
|
||||
select new { name = c.ExtName, city = city.ExtName })
|
||||
.ToListAsync();
|
||||
|
||||
var usedNames = await db.Farmers
|
||||
.Where(f => f.County != null && f.County != "")
|
||||
.Select(f => f.County)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
var used = new HashSet<string>(usedNames, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return Ok(items.Select(c => new { c.name, c.city, used = used.Contains(c.name) }));
|
||||
}
|
||||
|
||||
/// <summary>按县获取乡镇(街道)列表(公开数据 + 农户表已录入数据补充)</summary>
|
||||
[HttpGet("townships")]
|
||||
public async Task<ActionResult<string[]>> Townships(string? county, string? city)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(county)) return BadRequest(new { message = "请先选择县(区)" });
|
||||
|
||||
var countyQuery = db.Regions.Where(r => r.Deep == 2 && r.ExtName == county);
|
||||
if (!string.IsNullOrWhiteSpace(city))
|
||||
{
|
||||
var cityRow = await db.Regions.FirstOrDefaultAsync(r => r.Deep == 1 && r.ExtName == city);
|
||||
if (cityRow != null) countyQuery = countyQuery.Where(r => r.Pid == cityRow.Id);
|
||||
}
|
||||
var ids = await countyQuery.Select(r => r.Id).ToListAsync();
|
||||
|
||||
var builtin = ids.Count > 0
|
||||
? await db.Regions.Where(r => r.Deep == 3 && ids.Contains(r.Pid))
|
||||
.OrderBy(r => r.Id).Select(r => r.ExtName).ToListAsync()
|
||||
: [];
|
||||
|
||||
var fromDb = await db.Farmers
|
||||
.Where(f => f.County == county && f.Township != null && f.Township != "")
|
||||
.Select(f => f.Township)
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
return Ok(builtin.Union(fromDb).OrderBy(x => x).ToArray());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 村列表(来自农户表,按县/乡镇过滤去重),返回 { name, township } 对象数组。
|
||||
/// 不传 township 时返回全县所有村(含所属乡镇),供前端做同名村检测。
|
||||
/// </summary>
|
||||
[HttpGet("villages")]
|
||||
public async Task<ActionResult> Villages(string? county, string? township)
|
||||
{
|
||||
var q = db.Farmers.Where(f => f.Village != null && f.Village != "");
|
||||
if (!string.IsNullOrWhiteSpace(township))
|
||||
q = q.Where(f => f.Township == township);
|
||||
else if (!string.IsNullOrWhiteSpace(county))
|
||||
q = q.Where(f => f.County == county);
|
||||
var items = await q
|
||||
.Select(f => new { f.Village, f.Township })
|
||||
.Distinct()
|
||||
.OrderBy(x => x.Village)
|
||||
.ToListAsync();
|
||||
return Ok(items.Select(x => new { name = x.Village, township = x.Township ?? "" }));
|
||||
}
|
||||
|
||||
/// <summary>组列表(来自农户表,按县/乡镇/村过滤去重)</summary>
|
||||
[HttpGet("groups")]
|
||||
public async Task<ActionResult<string[]>> Groups(string? county, string? township, string? village)
|
||||
{
|
||||
var q = db.Farmers.Where(f => f.GroupName != null && f.GroupName != "");
|
||||
if (!string.IsNullOrWhiteSpace(village))
|
||||
q = q.Where(f => f.Village == village);
|
||||
else if (!string.IsNullOrWhiteSpace(township))
|
||||
q = q.Where(f => f.Township == township);
|
||||
else if (!string.IsNullOrWhiteSpace(county))
|
||||
q = q.Where(f => f.County == county);
|
||||
var items = await q.Select(f => f.GroupName).Distinct().OrderBy(x => x).ToListAsync();
|
||||
return Ok(items);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/reports")]
|
||||
[Authorize]
|
||||
public class ReportsController(AppDbContext db, DataScopeService scope, CurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
/// <summary>收购汇总报表(维度:day=按日 / month=按月 / product=按品种 / farmer=按农户 / org=按收购方)</summary>
|
||||
[HttpGet("purchase-summary")]
|
||||
public async Task<ActionResult<PurchaseSummaryResult>> PurchaseSummary(
|
||||
string dimension = "day", DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
var start = from ?? DateTime.Today.AddDays(-29);
|
||||
var end = (to ?? DateTime.Today).Date.AddDays(1);
|
||||
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PurchaseOrders
|
||||
.Where(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= start && p.CreatedAt < end)
|
||||
.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => visible.Contains(p.PurchaserOrgId));
|
||||
|
||||
var rows = dimension switch
|
||||
{
|
||||
"month" => await query
|
||||
.GroupBy(p => new { p.CreatedAt.Year, p.CreatedAt.Month })
|
||||
.Select(g => new
|
||||
{
|
||||
Key = $"{g.Key.Year}-{g.Key.Month:D2}",
|
||||
Amount = g.Sum(p => p.Amount),
|
||||
Weight = g.Sum(p => p.NetWeight),
|
||||
Cnt = g.Count()
|
||||
})
|
||||
.OrderBy(g => g.Key).ToListAsync(),
|
||||
"product" => await query
|
||||
.GroupBy(p => p.Product!.Name)
|
||||
.Select(g => new { Key = g.Key, Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight), Cnt = g.Count() })
|
||||
.OrderByDescending(g => g.Amount).ToListAsync(),
|
||||
"farmer" => await query
|
||||
.GroupBy(p => p.Farmer!.Name)
|
||||
.Select(g => new { Key = g.Key, Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight), Cnt = g.Count() })
|
||||
.OrderByDescending(g => g.Amount).Take(20).ToListAsync(),
|
||||
"org" => await query
|
||||
.GroupBy(p => p.PurchaserOrg!.Name)
|
||||
.Select(g => new { Key = g.Key, Amount = g.Sum(p => p.Amount), Weight = g.Sum(p => p.NetWeight), Cnt = g.Count() })
|
||||
.OrderByDescending(g => g.Amount).ToListAsync(),
|
||||
_ => await query
|
||||
.GroupBy(p => new { p.CreatedAt.Year, p.CreatedAt.Month, p.CreatedAt.Day })
|
||||
.Select(g => new
|
||||
{
|
||||
Key = $"{g.Key.Year}-{g.Key.Month:D2}-{g.Key.Day:D2}",
|
||||
Amount = g.Sum(p => p.Amount),
|
||||
Weight = g.Sum(p => p.NetWeight),
|
||||
Cnt = g.Count()
|
||||
})
|
||||
.OrderBy(g => g.Key).ToListAsync()
|
||||
};
|
||||
|
||||
return Ok(new PurchaseSummaryResult(
|
||||
dimension, start, end,
|
||||
rows.Sum(r => r.Amount), rows.Sum(r => r.Weight), rows.Sum(r => r.Cnt),
|
||||
rows.Select(r => new SummaryRow(r.Key, r.Key, r.Amount, r.Cnt)).ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>付款统计报表</summary>
|
||||
[HttpGet("payment-summary")]
|
||||
public async Task<ActionResult<PaymentSummaryResult>> PaymentSummary(DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
var start = from ?? DateTime.Today.AddDays(-29);
|
||||
var end = (to ?? DateTime.Today).Date.AddDays(1);
|
||||
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.PaymentRecords.AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(p => p.PurchaserOrgId != null && visible.Contains(p.PurchaserOrgId.Value));
|
||||
|
||||
var inRange = query.Where(p => p.CreatedAt >= start && p.CreatedAt < end);
|
||||
var totalPaid = await inRange.Where(p => p.Status == PayStatus.Success).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var totalPending = await inRange.Where(p => p.Status == PayStatus.Pending).SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||
var payCount = await inRange.Where(p => p.Status == PayStatus.Success).CountAsync();
|
||||
|
||||
var byMethod = await inRange.GroupBy(p => p.Method.ToString())
|
||||
.Select(g => new { Key = g.Key, Value = g.Sum(p => p.Amount), Cnt = g.Count() })
|
||||
.ToListAsync();
|
||||
var byStatus = await inRange.GroupBy(p => p.Status.ToString())
|
||||
.Select(g => new { Key = g.Key, Value = g.Sum(p => p.Amount), Cnt = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new PaymentSummaryResult(start, end, totalPaid, totalPending, payCount,
|
||||
byMethod.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray(),
|
||||
byStatus.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray()));
|
||||
}
|
||||
|
||||
/// <summary>开票统计报表</summary>
|
||||
[HttpGet("invoice-summary")]
|
||||
public async Task<ActionResult<InvoiceSummaryResult>> InvoiceSummary(DateTime? from = null, DateTime? to = null)
|
||||
{
|
||||
var start = from ?? DateTime.Today.AddDays(-29);
|
||||
var end = (to ?? DateTime.Today).Date.AddDays(1);
|
||||
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.Invoices.Where(i => i.IssueDate >= start && i.IssueDate < end).AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(i => visible.Contains(i.PurchaserOrgId));
|
||||
|
||||
var issued = query.Where(i => i.Status == InvoiceStatus.Issued);
|
||||
var totalAmount = await issued.SumAsync(i => (decimal?)i.Amount) ?? 0;
|
||||
var totalTax = await issued.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
|
||||
var issueCount = await issued.CountAsync();
|
||||
|
||||
var byProduct = await issued.GroupBy(i => i.Product!.Name)
|
||||
.Select(g => new { Key = g.Key, Value = g.Sum(i => i.Amount), Cnt = g.Count() })
|
||||
.OrderByDescending(g => g.Value).ToListAsync();
|
||||
var byStatus = await query.GroupBy(i => i.Status.ToString())
|
||||
.Select(g => new { Key = g.Key, Value = g.Sum(i => i.Amount), Cnt = g.Count() })
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new InvoiceSummaryResult(start, end, totalAmount, totalTax, issueCount,
|
||||
byProduct.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray(),
|
||||
byStatus.Select(g => new SummaryRow(g.Key, g.Key, g.Value, g.Cnt)).ToArray()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Net.NetworkInformation;
|
||||
using System.Net.Sockets;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 附件上传(身份证正反面):高拍仪/本地拍照直传 + 手机扫码上传。
|
||||
/// 图片保存为附件(本地存储,OSS 配置后自动切换),并返回 OCR 识别结果。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/uploads")]
|
||||
public class UploadsController(
|
||||
IFileStorage storage,
|
||||
IOcrService ocr,
|
||||
MobileUploadSessionStore sessions,
|
||||
IConfiguration cfg) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 高拍仪/本地拍照:上传身份证单面图片(side = front 正面 | back 反面),
|
||||
/// 返回附件地址(url/key)与 OCR 识别结果。
|
||||
/// </summary>
|
||||
[HttpPost("idcard")]
|
||||
[Authorize]
|
||||
[RequestSizeLimit(15 * 1024 * 1024)]
|
||||
public async Task<ActionResult> UploadIdCard(IFormFile? file, [FromForm] string? side)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { message = "请先拍摄或选择身份证图片" });
|
||||
|
||||
side = side == "back" ? "back" : "front";
|
||||
await using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
StoredFile saved;
|
||||
try
|
||||
{
|
||||
saved = await storage.SaveAsync("idcard", ms, file.FileName, file.ContentType);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
|
||||
var result = await ocr.RecognizeAsync(ms, side, file.FileName);
|
||||
return Ok(new { url = saved.Url, key = saved.Key, side, ocr = result });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手机传图:创建上传会话,返回二维码内容(手机访问的 URL)。
|
||||
/// 电脑端展示二维码,手机扫码后访问 /mobile-upload.html?code=xxx 上传身份证正反面。
|
||||
/// 注意:端口固定取 Server:LanPort(默认 5246),不依赖请求 Host,
|
||||
/// 因为经 Vite 代理(5173)访问时 Host 端口可能是前端端口,手机无法访问该端口。
|
||||
/// </summary>
|
||||
[HttpGet("qr")]
|
||||
[Authorize]
|
||||
public ActionResult<object> CreateQr()
|
||||
{
|
||||
var code = sessions.Create();
|
||||
// 优先取 TCP 连接本端地址(后端监听 0.0.0.0 时即手机可直达的局域网 IP),回环则回退枚举网卡
|
||||
var localIp = HttpContext.Connection.LocalIpAddress?.ToString() ?? "";
|
||||
var lanIp = localIp.StartsWith("127.") || localIp.StartsWith("::1") || localIp.StartsWith("::ffff:127.")
|
||||
? GetLanIp()
|
||||
: localIp;
|
||||
var port = cfg["Server:LanPort"] ?? "5246";
|
||||
var url = $"http://{lanIp}:{port}/mobile-upload.html?code={code}";
|
||||
return Ok(new { code, url, expiresAt = DateTime.Now.AddMinutes(10) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 农户头像上传:摄像头/本地图片直传,返回附件地址。
|
||||
/// 图片保存于 avatar 目录,未来可用于人脸识别收购(届时在识别服务中引用 AvatarUrl)。
|
||||
/// </summary>
|
||||
[HttpPost("avatar")]
|
||||
[Authorize]
|
||||
[RequestSizeLimit(15 * 1024 * 1024)]
|
||||
public async Task<ActionResult> UploadAvatar(IFormFile? file)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
return BadRequest(new { message = "请先拍摄或选择头像图片" });
|
||||
|
||||
await using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
|
||||
StoredFile saved;
|
||||
try
|
||||
{
|
||||
saved = await storage.SaveAsync("avatar", ms, file.FileName, file.ContentType);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
|
||||
return Ok(new { url = saved.Url, key = saved.Key });
|
||||
}
|
||||
|
||||
/// <summary>手机上传身份证正反面(front/back 两张图,允许只传一张)</summary>
|
||||
[HttpPost("mobile")]
|
||||
[AllowAnonymous]
|
||||
[RequestSizeLimit(30 * 1024 * 1024)]
|
||||
public async Task<ActionResult> MobileUpload(
|
||||
[FromForm] string? code,
|
||||
IFormFile? front,
|
||||
IFormFile? back)
|
||||
{
|
||||
var session = sessions.Get(code ?? "");
|
||||
if (session is null || session.Done)
|
||||
return BadRequest(new { message = "二维码已失效,请回到电脑端重新生成" });
|
||||
|
||||
if (front is not null && front.Length > 0)
|
||||
{
|
||||
await using var ms = new MemoryStream();
|
||||
await front.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
try
|
||||
{
|
||||
session.Front = await storage.SaveAsync("idcard", ms, front.FileName, front.ContentType);
|
||||
session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
if (back is not null && back.Length > 0)
|
||||
{
|
||||
await using var ms = new MemoryStream();
|
||||
await back.CopyToAsync(ms);
|
||||
ms.Position = 0;
|
||||
try
|
||||
{
|
||||
session.Back = await storage.SaveAsync("idcard", ms, back.FileName, back.ContentType);
|
||||
session.BackOcr = await ocr.RecognizeAsync(ms, "back", back.FileName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
session.Done = true;
|
||||
return Ok(new { ok = true, message = "上传成功,请回到电脑端查看" });
|
||||
}
|
||||
|
||||
/// <summary>电脑端轮询手机上传结果(done=false 表示手机还未传完)</summary>
|
||||
[HttpGet("mobile/{code}/result")]
|
||||
[Authorize]
|
||||
public ActionResult<object> MobileResult(string code)
|
||||
{
|
||||
var session = sessions.Get(code);
|
||||
if (session is null)
|
||||
return NotFound(new { message = "二维码已失效,请重新生成" });
|
||||
if (!session.Done)
|
||||
return Ok(new { done = false });
|
||||
|
||||
var ocr = MergeOcr(session.FrontOcr, session.BackOcr);
|
||||
return Ok(new
|
||||
{
|
||||
done = true,
|
||||
frontUrl = session.Front?.Url,
|
||||
frontKey = session.Front?.Key,
|
||||
backUrl = session.Back?.Url,
|
||||
backKey = session.Back?.Key,
|
||||
ocr
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>合并正反面 OCR 结果(正面的姓名/证件号/性别/住址为主)</summary>
|
||||
private static IdCardOcrResult MergeOcr(IdCardOcrResult? front, IdCardOcrResult? back)
|
||||
{
|
||||
var any = front is { Success: true } || back is { Success: true };
|
||||
var f = front ?? new IdCardOcrResult();
|
||||
return new IdCardOcrResult(
|
||||
f.Name, f.IdCard, f.Gender, f.Address, f.BankName, f.BankAccount,
|
||||
any, any ? "识别完成" : (f.Message ?? "未配置 OCR 服务"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取本机局域网 IP,供手机扫码访问。
|
||||
/// 优先选择物理网卡(排除 VMware/VirtualBox/Hyper-V/WSL/VPN 等虚拟网卡),
|
||||
/// 避免多网卡环境(虚拟机、VPN、Docker)下拿到错误的 IP 导致手机无法访问。
|
||||
/// </summary>
|
||||
private static string GetLanIp()
|
||||
{
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
if (IsVirtualAdapter(ni)) continue;
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
var ip = ua.Address.ToString();
|
||||
if (ip.StartsWith("192.168.") || ip.StartsWith("10.") || ip.StartsWith("172."))
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底:物理网卡找不到时,在虚拟网卡里再找一次
|
||||
foreach (var ni in NetworkInterface.GetAllNetworkInterfaces())
|
||||
{
|
||||
if (ni.OperationalStatus != OperationalStatus.Up) continue;
|
||||
foreach (var ua in ni.GetIPProperties().UnicastAddresses)
|
||||
{
|
||||
if (ua.Address.AddressFamily != AddressFamily.InterNetwork) continue;
|
||||
var ip = ua.Address.ToString();
|
||||
if (ip.StartsWith("192.168.") || ip.StartsWith("10.") || ip.StartsWith("172."))
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
return "127.0.0.1";
|
||||
}
|
||||
|
||||
/// <summary>根据网卡描述判断是否为虚拟/隧道/环回网卡</summary>
|
||||
private static bool IsVirtualAdapter(NetworkInterface ni)
|
||||
{
|
||||
var desc = (ni.Description ?? "").ToLowerInvariant();
|
||||
return desc.Contains("virtual") || desc.Contains("vmware") || desc.Contains("virtualbox")
|
||||
|| desc.Contains("hyper-v") || desc.Contains("tunnel") || desc.Contains("loopback")
|
||||
|| desc.Contains("docker") || desc.Contains("wsl") || desc.Contains("tailscale")
|
||||
|| desc.Contains("zerotier") || desc.Contains("vpn") || desc.Contains("tap-")
|
||||
|| desc.Contains("tun-") || desc.Contains("hamachi");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Dtos;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/users")]
|
||||
[Authorize]
|
||||
public class UsersController(AppDbContext db, DataScopeService scope, CurrentUserService currentUser) : ControllerBase
|
||||
{
|
||||
/// <summary>用户列表(分页 + 数据权限)</summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<UserDto>>> List(
|
||||
string? role, string? orgId,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? keyword = null)
|
||||
{
|
||||
var visible = await scope.GetVisibleOrgIdsAsync(currentUser.Get());
|
||||
var query = db.Users.Include(u => u.Org).AsQueryable();
|
||||
if (visible is not null)
|
||||
query = query.Where(u => u.OrgId != null && visible.Contains(u.OrgId.Value));
|
||||
if (!string.IsNullOrWhiteSpace(role) && Enum.TryParse<UserRole>(role, out var userRole))
|
||||
query = query.Where(u => u.Role == userRole);
|
||||
if (int.TryParse(orgId, out var oid))
|
||||
query = query.Where(u => u.OrgId == oid);
|
||||
if (!string.IsNullOrWhiteSpace(keyword))
|
||||
query = query.Where(u => u.Username.Contains(keyword) || u.RealName.Contains(keyword) || u.Phone.Contains(keyword));
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var items = await query.OrderByDescending(u => u.Id)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.ToListAsync();
|
||||
|
||||
return Ok(new PagedResult<UserDto>(items.ToDtos(), total));
|
||||
}
|
||||
|
||||
/// <summary>新增用户</summary>
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<UserDto>> Create(UserSaveRequest req)
|
||||
{
|
||||
if (await db.Users.AnyAsync(u => u.Username == req.Username))
|
||||
return BadRequest(new { message = "用户名已存在" });
|
||||
if (!Enum.TryParse<UserRole>(req.Role, out var role))
|
||||
return BadRequest(new { message = "角色不正确" });
|
||||
|
||||
var user = new User
|
||||
{
|
||||
Username = req.Username,
|
||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password ?? "123456"),
|
||||
RealName = req.RealName,
|
||||
Phone = req.Phone,
|
||||
Role = role,
|
||||
OrgId = req.OrgId,
|
||||
IsActive = req.IsActive
|
||||
};
|
||||
db.Users.Add(user);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok((await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == user.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>修改用户</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult<UserDto>> Update(int id, UserSaveRequest req)
|
||||
{
|
||||
var user = await db.Users.FindAsync(id);
|
||||
if (user is null) return NotFound();
|
||||
if (req.Username != user.Username && await db.Users.AnyAsync(u => u.Username == req.Username))
|
||||
return BadRequest(new { message = "用户名已存在" });
|
||||
|
||||
user.Username = req.Username;
|
||||
user.RealName = req.RealName;
|
||||
user.Phone = req.Phone;
|
||||
if (Enum.TryParse<UserRole>(req.Role, out var role)) user.Role = role;
|
||||
user.OrgId = req.OrgId;
|
||||
user.IsActive = req.IsActive;
|
||||
if (!string.IsNullOrWhiteSpace(req.Password))
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(req.Password);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok((await db.Users.Include(u => u.Org).FirstAsync(u => u.Id == user.Id)).ToDto());
|
||||
}
|
||||
|
||||
/// <summary>重置密码</summary>
|
||||
[HttpPut("{id:int}/reset-password")]
|
||||
public async Task<IActionResult> ResetPassword(int id, [FromBody] string? newPassword = null)
|
||||
{
|
||||
var user = await db.Users.FindAsync(id);
|
||||
if (user is null) return NotFound();
|
||||
user.PasswordHash = BCrypt.Net.BCrypt.HashPassword(newPassword ?? "123456");
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "密码已重置为 " + (newPassword ?? "123456") });
|
||||
}
|
||||
|
||||
/// <summary>删除用户(禁止删除自己)</summary>
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var cu = currentUser.Get()!;
|
||||
if (id == cu.Id) return BadRequest(new { message = "不能删除当前登录账号" });
|
||||
var user = await db.Users.FindAsync(id);
|
||||
if (user is null) return NotFound();
|
||||
db.Users.Remove(user);
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(new { message = "删除成功" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<User> Users => Set<User>();
|
||||
public DbSet<Organization> Organizations => Set<Organization>();
|
||||
public DbSet<Farmer> Farmers => Set<Farmer>();
|
||||
public DbSet<Region> Regions => Set<Region>();
|
||||
public DbSet<Product> Products => Set<Product>();
|
||||
public DbSet<PurchaseOrder> PurchaseOrders => Set<PurchaseOrder>();
|
||||
public DbSet<PaymentRecord> PaymentRecords => Set<PaymentRecord>();
|
||||
public DbSet<Invoice> Invoices => Set<Invoice>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// 所有枚举属性以字符串形式存储,便于阅读
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
if (property.ClrType.IsEnum)
|
||||
{
|
||||
var converterType = typeof(EnumToStringConverter<>).MakeGenericType(property.ClrType);
|
||||
var converter = (ValueConverter)Activator.CreateInstance(converterType)!;
|
||||
property.SetValueConverter(converter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户
|
||||
modelBuilder.Entity<User>()
|
||||
.Property(u => u.Username).HasMaxLength(50);
|
||||
modelBuilder.Entity<User>()
|
||||
.HasIndex(u => u.Username).IsUnique();
|
||||
modelBuilder.Entity<User>()
|
||||
.HasOne(u => u.Org).WithMany().HasForeignKey(u => u.OrgId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// 组织
|
||||
modelBuilder.Entity<Organization>()
|
||||
.Property(o => o.Type).HasMaxLength(32);
|
||||
modelBuilder.Entity<Organization>()
|
||||
.Property(o => o.Name).HasMaxLength(200);
|
||||
modelBuilder.Entity<Organization>()
|
||||
.HasIndex(o => new { o.Type, o.Name });
|
||||
modelBuilder.Entity<Organization>()
|
||||
.HasOne(o => o.Parent).WithMany().HasForeignKey(o => o.ParentId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// 农户:身份证唯一
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.IdCard).HasMaxLength(18);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.HasIndex(f => f.IdCard).IsUnique();
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.Phone).HasMaxLength(20);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.HasIndex(f => f.Phone);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.FarmerType).HasMaxLength(20);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.IdCardFrontUrl).HasMaxLength(500);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.IdCardBackUrl).HasMaxLength(500);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.Province).HasMaxLength(50);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.County).HasMaxLength(50);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.Township).HasMaxLength(50);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.Village).HasMaxLength(100);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.Property(f => f.GroupName).HasMaxLength(50);
|
||||
modelBuilder.Entity<Farmer>()
|
||||
.HasIndex(f => new { f.Province, f.County, f.Township });
|
||||
|
||||
// 行政区划(AreaCity 全国数据,表由导入脚本/启动建表创建)
|
||||
modelBuilder.Entity<Region>()
|
||||
.HasKey(r => r.Id);
|
||||
modelBuilder.Entity<Region>()
|
||||
.Property(r => r.Name).HasMaxLength(100);
|
||||
modelBuilder.Entity<Region>()
|
||||
.Property(r => r.ExtName).HasMaxLength(100);
|
||||
modelBuilder.Entity<Region>()
|
||||
.HasIndex(r => r.Pid);
|
||||
modelBuilder.Entity<Region>()
|
||||
.HasIndex(r => r.Deep);
|
||||
|
||||
// 品种
|
||||
modelBuilder.Entity<Product>()
|
||||
.Property(p => p.Name).HasMaxLength(100);
|
||||
modelBuilder.Entity<Product>()
|
||||
.HasIndex(p => p.Name).IsUnique();
|
||||
|
||||
// 收购单
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.Property(p => p.OrderNo).HasMaxLength(32);
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.HasIndex(p => p.OrderNo).IsUnique();
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.HasIndex(p => new { p.Status, p.CreatedAt });
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.HasOne(p => p.Farmer).WithMany().HasForeignKey(p => p.FarmerId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.HasOne(p => p.Product).WithMany().HasForeignKey(p => p.ProductId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.HasOne(p => p.PurchaserOrg).WithMany().HasForeignKey(p => p.PurchaserOrgId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<PurchaseOrder>()
|
||||
.HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// 支付
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.Property(p => p.PayNo).HasMaxLength(32);
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.HasIndex(p => p.PayNo).IsUnique();
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.HasIndex(p => new { p.Status, p.CreatedAt });
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.HasOne(p => p.Farmer).WithMany().HasForeignKey(p => p.FarmerId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.HasOne(p => p.PurchaseOrder).WithMany().HasForeignKey(p => p.PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.HasOne(p => p.PurchaserOrg).WithMany().HasForeignKey(p => p.PurchaserOrgId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<PaymentRecord>()
|
||||
.HasOne(p => p.Operator).WithMany().HasForeignKey(p => p.OperatorId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// 发票
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.Property(i => i.InvoiceNo).HasMaxLength(32);
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasIndex(i => i.InvoiceNo).IsUnique();
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.Property(i => i.BatchNo).HasMaxLength(32);
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasIndex(i => i.BatchNo);
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasIndex(i => new { i.Status, i.IssueDate });
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasOne(i => i.Farmer).WithMany().HasForeignKey(i => i.FarmerId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasOne(i => i.PurchaserOrg).WithMany().HasForeignKey(i => i.PurchaserOrgId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasOne(i => i.PurchaseOrder).WithMany().HasForeignKey(i => i.PurchaseOrderId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
modelBuilder.Entity<Invoice>()
|
||||
.HasOne(i => i.Operator).WithMany().HasForeignKey(i => i.OperatorId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// 金额/重量精度
|
||||
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
|
||||
{
|
||||
foreach (var property in entityType.GetProperties())
|
||||
{
|
||||
if (property.ClrType == typeof(decimal) || property.ClrType == typeof(decimal?))
|
||||
{
|
||||
property.SetPrecision(18);
|
||||
property.SetScale(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Data;
|
||||
|
||||
/// <summary>初始化数据(仅在空库时执行)</summary>
|
||||
public static class DbSeeder
|
||||
{
|
||||
public static void Seed(AppDbContext db)
|
||||
{
|
||||
if (db.Users.Any()) return;
|
||||
|
||||
var now = DateTime.Now;
|
||||
|
||||
// ---------- 组织 ----------
|
||||
var company = new Organization
|
||||
{
|
||||
Type = OrgType.Company,
|
||||
Name = "绿源农产品收购有限公司",
|
||||
ContactPerson = "王建国",
|
||||
Phone = "13800000001",
|
||||
Address = "河南省郑州市中牟县官渡大道66号",
|
||||
TaxNo = "91410100MA3X8K7Q2B",
|
||||
CreatedAt = now
|
||||
};
|
||||
var stationEast = new Organization
|
||||
{
|
||||
Type = OrgType.Station,
|
||||
Name = "城东收购站",
|
||||
ContactPerson = "李强",
|
||||
Phone = "13800000002",
|
||||
Address = "中牟县官渡镇城东村",
|
||||
CreatedAt = now
|
||||
};
|
||||
var stationWest = new Organization
|
||||
{
|
||||
Type = OrgType.Station,
|
||||
Name = "城西收购站",
|
||||
ContactPerson = "赵敏",
|
||||
Phone = "13800000003",
|
||||
Address = "中牟县韩寺镇城西村",
|
||||
CreatedAt = now
|
||||
};
|
||||
var individual = new Organization
|
||||
{
|
||||
Type = OrgType.Individual,
|
||||
Name = "张伟(个体收购)",
|
||||
ContactPerson = "张伟",
|
||||
Phone = "13800000004",
|
||||
Address = "中牟县刁家乡",
|
||||
TaxNo = "410122197501011234",
|
||||
CreatedAt = now
|
||||
};
|
||||
|
||||
db.Organizations.AddRange(company, stationEast, stationWest, individual);
|
||||
db.SaveChanges();
|
||||
stationEast.ParentId = company.Id;
|
||||
stationWest.ParentId = company.Id;
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 用户(默认密码均为 123456) ----------
|
||||
var users = new List<User>
|
||||
{
|
||||
new() { Username = "admin", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "系统管理员", Phone = "13800000000", Role = UserRole.SuperAdmin, CreatedAt = now },
|
||||
new() { Username = "company", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "王建国", Phone = "13800000001", Role = UserRole.CompanyAdmin, OrgId = company.Id, CreatedAt = now },
|
||||
new() { Username = "station1", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "李强", Phone = "13800000002", Role = UserRole.StationStaff, OrgId = stationEast.Id, CreatedAt = now },
|
||||
new() { Username = "station2", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "赵敏", Phone = "13800000003", Role = UserRole.StationStaff, OrgId = stationWest.Id, CreatedAt = now },
|
||||
new() { Username = "individual", PasswordHash = BCrypt.Net.BCrypt.HashPassword("123456"), RealName = "张伟", Phone = "13800000004", Role = UserRole.Individual, OrgId = individual.Id, CreatedAt = now }
|
||||
};
|
||||
db.Users.AddRange(users);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 品种 ----------
|
||||
var products = new List<Product>
|
||||
{
|
||||
new() { Name = "小麦", Category = "粮食", Unit = "公斤", Spec = "水分≤13%,容重≥750g/L", Price = 2.42m },
|
||||
new() { Name = "玉米", Category = "粮食", Unit = "公斤", Spec = "水分≤14%,杂质≤1%", Price = 2.18m },
|
||||
new() { Name = "水稻", Category = "粮食", Unit = "公斤", Spec = "出糙率≥77%", Price = 2.68m },
|
||||
new() { Name = "花生", Category = "油料", Unit = "公斤", Spec = "果仁饱满,出仁率≥70%", Price = 7.80m },
|
||||
new() { Name = "大蒜", Category = "蔬菜", Unit = "公斤", Spec = "5cm以上,干度达标", Price = 4.50m },
|
||||
new() { Name = "西红柿", Category = "蔬菜", Unit = "公斤", Spec = "一级果,单果150g以上", Price = 2.60m },
|
||||
new() { Name = "苹果", Category = "水果", Unit = "公斤", Spec = "红富士,直径75mm以上", Price = 5.20m },
|
||||
new() { Name = "棉花", Category = "经济作物", Unit = "公斤", Spec = "衣分≥38%", Price = 15.60m }
|
||||
};
|
||||
db.Products.AddRange(products);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 农户 ----------
|
||||
var farmers = new List<Farmer>
|
||||
{
|
||||
new() { Name = "刘老汉", IdCard = "410122197003120011", Gender = "男", Phone = "13911110001", Village = "城东村一组", Address = "城东村一组12号", BankName = "中牟农商银行", BankAccount = "6228480012345678901", CreditScore = 92 },
|
||||
new() { Name = "陈秀英", IdCard = "410122196806250022", Gender = "女", Phone = "13911110002", Village = "城东村二组", Address = "城东村二组8号", BankName = "中牟农商银行", BankAccount = "6228480012345678902", CreditScore = 88 },
|
||||
new() { Name = "张铁柱", IdCard = "410122198204170033", Gender = "男", Phone = "13911110003", Village = "城西村一组", Address = "城西村一组21号", BankName = "农业银行", BankAccount = "6228480012345678903", CreditScore = 85 },
|
||||
new() { Name = "王翠花", IdCard = "410122197512080044", Gender = "女", Phone = "13911110004", Village = "城西村三组", Address = "城西村三组5号", BankName = "邮政储蓄银行", BankAccount = "6228480012345678904", CreditScore = 90 },
|
||||
new() { Name = "赵大勇", IdCard = "410122199003300055", Gender = "男", Phone = "13911110005", Village = "官渡镇贾庄村", Address = "贾庄村16号", BankName = "中牟农商银行", BankAccount = "6228480012345678905", CreditScore = 82 },
|
||||
new() { Name = "孙秀兰", IdCard = "410122196511150066", Gender = "女", Phone = "13911110006", Village = "韩寺镇马家村", Address = "马家村9号", BankName = "农业银行", BankAccount = "6228480012345678906", CreditScore = 78 },
|
||||
new() { Name = "李保田", IdCard = "410122197807210077", Gender = "男", Phone = "13911110007", Village = "刁家乡水沱寨村", Address = "水沱寨村3号", BankName = "邮政储蓄银行", BankAccount = "6228480012345678907", CreditScore = 86 },
|
||||
new() { Name = "周桂芳", IdCard = "410122198906060088", Gender = "女", Phone = "13911110008", Village = "官渡镇前于村", Address = "前于村28号", BankName = "中牟农商银行", BankAccount = "6228480012345678908", CreditScore = 91 }
|
||||
};
|
||||
db.Farmers.AddRange(farmers);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 历史收购单(近30天,用于报表与大屏演示) ----------
|
||||
var rnd = new Random(20260812);
|
||||
var purchaserIds = new[] { company.Id, stationEast.Id, stationWest.Id, individual.Id };
|
||||
var productIds = products.Select(p => p.Id).ToArray();
|
||||
var farmerIds = farmers.Select(f => f.Id).ToArray();
|
||||
var userIds = users.Select(u => u.Id).ToArray();
|
||||
|
||||
var orders = new List<PurchaseOrder>();
|
||||
for (int day = 29; day >= 0; day--)
|
||||
{
|
||||
int count = rnd.Next(3, 8);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var createAt = now.Date.AddDays(-day).AddHours(rnd.Next(7, 18)).AddMinutes(rnd.Next(0, 59));
|
||||
var productId = productIds[rnd.Next(productIds.Length)];
|
||||
var gross = rnd.Next(5000, 32000);
|
||||
var tare = rnd.Next(3200, 9000);
|
||||
var net = gross - tare;
|
||||
if (net <= 0) continue;
|
||||
var price = products.First(p => p.Id == productId).Price + (decimal)rnd.Next(-10, 10) / 10m;
|
||||
|
||||
orders.Add(new PurchaseOrder
|
||||
{
|
||||
OrderNo = $"CG{createAt:yyyyMMdd}-{(day * 10 + i):D4}",
|
||||
FarmerId = farmerIds[rnd.Next(farmerIds.Length)],
|
||||
ProductId = productId,
|
||||
PurchaserOrgId = purchaserIds[rnd.Next(purchaserIds.Length)],
|
||||
Grade = new[] { "一等", "二等", "三等" }[rnd.Next(3)],
|
||||
Unit = "公斤",
|
||||
UnitPrice = price,
|
||||
GrossWeight = gross,
|
||||
TareWeight = tare,
|
||||
NetWeight = net,
|
||||
Amount = Math.Round(net * price, 2),
|
||||
WeighCount = 2,
|
||||
Status = PurchaseStatus.Completed,
|
||||
OperatorId = userIds[rnd.Next(userIds.Length)],
|
||||
WeighInAt = createAt,
|
||||
WeighOutAt = createAt.AddMinutes(rnd.Next(15, 50)),
|
||||
CreatedAt = createAt,
|
||||
UpdatedAt = createAt
|
||||
});
|
||||
}
|
||||
}
|
||||
db.PurchaseOrders.AddRange(orders);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 历史付款(对应部分订单) ----------
|
||||
var payOrders = orders.Where(o => o.Status == PurchaseStatus.Completed)
|
||||
.OrderBy(o => o.CreatedAt).Take(120).ToList();
|
||||
var methods = new[] { PayMethod.Wechat, PayMethod.Alipay, PayMethod.BankTransfer, PayMethod.Cash };
|
||||
var payments = payOrders.Select((o, idx) => new PaymentRecord
|
||||
{
|
||||
PayNo = $"ZF{o.CreatedAt:yyyyMMdd}-{idx + 1:D4}",
|
||||
PurchaseOrderId = o.Id,
|
||||
FarmerId = o.FarmerId,
|
||||
Amount = o.Amount,
|
||||
Method = methods[rnd.Next(methods.Length)],
|
||||
Status = PayStatus.Success,
|
||||
TradeNo = $"TRADE{rnd.Next(100000000, 999999999)}",
|
||||
OperatorId = o.OperatorId,
|
||||
PaidAt = o.WeighOutAt,
|
||||
CreatedAt = o.WeighOutAt!.Value,
|
||||
Notes = $"收购单 {o.OrderNo} 货款结算"
|
||||
}).ToList();
|
||||
db.PaymentRecords.AddRange(payments);
|
||||
db.SaveChanges();
|
||||
|
||||
// ---------- 历史发票(对应部分订单,反向开票) ----------
|
||||
var invoiceOrders = orders.Where(o => o.Status == PurchaseStatus.Completed)
|
||||
.OrderByDescending(o => o.CreatedAt).Take(80).ToList();
|
||||
var invoices = invoiceOrders.Select((o, idx) => new Invoice
|
||||
{
|
||||
InvoiceNo = $"FP{o.CreatedAt:yyyyMMdd}-{idx + 1:D4}",
|
||||
InvoiceKind = "农产品收购发票",
|
||||
FarmerId = o.FarmerId,
|
||||
PurchaserOrgId = o.PurchaserOrgId,
|
||||
PurchaseOrderId = o.Id,
|
||||
ProductId = o.ProductId,
|
||||
Quantity = o.NetWeight,
|
||||
Unit = o.Unit,
|
||||
UnitPrice = o.UnitPrice,
|
||||
Amount = o.Amount,
|
||||
TaxRate = 0m,
|
||||
TaxAmount = 0m,
|
||||
IssueDate = o.WeighOutAt ?? o.CreatedAt,
|
||||
Status = InvoiceStatus.Issued,
|
||||
OperatorId = o.OperatorId,
|
||||
CreatedAt = o.WeighOutAt ?? o.CreatedAt
|
||||
}).ToList();
|
||||
db.Invoices.AddRange(invoices);
|
||||
db.SaveChanges();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// 兼容已存在的数据库:EnsureCreated 不会为已存在的表补列,
|
||||
/// 这里通过 information_schema 检测后逐列执行 ALTER TABLE,
|
||||
/// 为 Farmers 表补充四级行政区划等新列(兼容 MySQL 5.7 / 8.0 各版本)。
|
||||
/// </summary>
|
||||
public static class SchemaMigrator
|
||||
{
|
||||
public static async Task EnsureColumnsAsync(AppDbContext db)
|
||||
{
|
||||
var existing = new HashSet<string>(
|
||||
await db.Database
|
||||
.SqlQuery<string>($"SELECT COLUMN_NAME AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Farmers'")
|
||||
.ToListAsync(),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
(string Name, string Ddl)[] columns =
|
||||
[
|
||||
("Province", "Province NVARCHAR(50) NULL"),
|
||||
("County", "County NVARCHAR(50) NULL"),
|
||||
("Township", "Township NVARCHAR(50) NULL"),
|
||||
("GroupName", "GroupName NVARCHAR(50) NULL"),
|
||||
("FarmerType", "FarmerType NVARCHAR(20) NULL"),
|
||||
("IdCardFrontUrl", "IdCardFrontUrl NVARCHAR(500) NULL"),
|
||||
("IdCardBackUrl", "IdCardBackUrl NVARCHAR(500) NULL"),
|
||||
("AvatarUrl", "AvatarUrl NVARCHAR(500) NULL"),
|
||||
];
|
||||
|
||||
foreach (var col in columns)
|
||||
{
|
||||
if (existing.Contains(col.Name)) continue;
|
||||
await db.Database.ExecuteSqlRawAsync($"ALTER TABLE Farmers ADD COLUMN {col.Ddl}");
|
||||
}
|
||||
|
||||
// 历史数据的新列为 NULL,EF 映射为不可空 string 读取会抛 DBNull 异常,统一刷新为空串(幂等)
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE Farmers SET Province = COALESCE(Province,''), County = COALESCE(County,''), " +
|
||||
"Township = COALESCE(Township,''), GroupName = COALESCE(GroupName,''), " +
|
||||
"FarmerType = COALESCE(FarmerType,'农户'), " +
|
||||
"IdCardFrontUrl = COALESCE(IdCardFrontUrl,''), IdCardBackUrl = COALESCE(IdCardBackUrl,''), " +
|
||||
"AvatarUrl = COALESCE(AvatarUrl,'')");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确保 Regions(行政区划)表存在。
|
||||
/// 表结构与 scripts/areacity/import-regions.mjs 导入脚本一致;
|
||||
/// 数据由该脚本导入(全国省市区乡镇四级,约 4.2 万条)。
|
||||
/// </summary>
|
||||
public static async Task EnsureRegionsTableAsync(AppDbContext db)
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS Regions (
|
||||
Id INT NOT NULL PRIMARY KEY,
|
||||
Pid INT NOT NULL,
|
||||
Deep INT NOT NULL,
|
||||
Name VARCHAR(100) NOT NULL,
|
||||
ExtName VARCHAR(100) NOT NULL,
|
||||
Pinyin VARCHAR(200) NOT NULL DEFAULT '',
|
||||
PinyinPrefix VARCHAR(10) NOT NULL DEFAULT '',
|
||||
Hot INT NOT NULL DEFAULT 0,
|
||||
KEY idx_regions_pid (Pid),
|
||||
KEY idx_regions_deep (Deep)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci
|
||||
""");
|
||||
// 老库补充 Hot 热度列(幂等)
|
||||
var hotExists = await db.Database.SqlQuery<int>(
|
||||
$"SELECT COUNT(*) AS Value FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'Regions' AND COLUMN_NAME = 'Hot'")
|
||||
.FirstOrDefaultAsync();
|
||||
if (hotExists == 0)
|
||||
await db.Database.ExecuteSqlRawAsync("ALTER TABLE Regions ADD COLUMN Hot INT NOT NULL DEFAULT 0");
|
||||
|
||||
// 常用省份热度值(值越大越靠前,幂等可重复执行)
|
||||
(string Name, int Hot)[] hotProvinces =
|
||||
[
|
||||
("四川省", 20), ("广西壮族自治区", 19), ("陕西省", 18), ("重庆市", 17), ("安徽省", 16),
|
||||
("云南省", 15), ("贵州省", 14), ("河南省", 13), ("山东省", 12), ("湖北省", 11), ("湖南省", 10),
|
||||
("甘肃省", 9), ("新疆维吾尔自治区", 8), ("江西省", 7), ("福建省", 6), ("广东省", 5),
|
||||
("河北省", 4), ("山西省", 3), ("江苏省", 2), ("浙江省", 1),
|
||||
];
|
||||
foreach (var p in hotProvinces)
|
||||
await db.Database.ExecuteSqlRawAsync(
|
||||
"UPDATE Regions SET Hot = {0} WHERE Deep = 0 AND ExtName = {1}", p.Hot, p.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
// ---------- 认证 ----------
|
||||
public record LoginRequest(string Username, string Password);
|
||||
|
||||
public record LoginResult(string Token, UserInfoDto User);
|
||||
|
||||
public record UserInfoDto(
|
||||
int Id, string Username, string RealName, string Phone, string Role,
|
||||
int? OrgId, string? OrgName, string? OrgType, bool IsActive);
|
||||
|
||||
public record ChangePasswordRequest(string OldPassword, string NewPassword);
|
||||
|
||||
// ---------- 通用分页 ----------
|
||||
public record PagedResult<T>(IEnumerable<T> Items, int Total);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
/// <summary>大屏概览 KPI(按日期区间统计)</summary>
|
||||
public record OverviewDto(
|
||||
decimal Amount, int Orders, decimal NetWeight, decimal Paid,
|
||||
decimal AvgPrice, int FarmerCount, int WeighingCount);
|
||||
|
||||
/// <summary>趋势点</summary>
|
||||
public record TrendPoint(string Date, decimal Amount, decimal NetWeight);
|
||||
|
||||
/// <summary>占比点</summary>
|
||||
public record RatioPoint(string Name, decimal Value);
|
||||
|
||||
/// <summary>农户收购金额排行点</summary>
|
||||
public record FarmerTopPoint(int FarmerId, string Name, decimal Amount, int Count, decimal NetWeight);
|
||||
|
||||
/// <summary>站点对比点</summary>
|
||||
public record StationPoint(int OrgId, string Name, decimal Amount, int Count, decimal NetWeight);
|
||||
|
||||
/// <summary>收购单价排行点(按品种平均单价)</summary>
|
||||
public record PriceTopPoint(string Name, decimal AvgPrice, decimal NetWeight, int Count);
|
||||
|
||||
/// <summary>农户收入排行点(收入 = 区间收购金额,含已付)</summary>
|
||||
public record IncomeTopPoint(int FarmerId, string Name, decimal Amount, decimal Paid, int Count);
|
||||
|
||||
/// <summary>实时过磅(进行中的收购单)</summary>
|
||||
public record RealtimeWeighing(
|
||||
int Id, string OrderNo, string FarmerName, string ProductName,
|
||||
string Status, decimal GrossWeight, DateTime WeighInAt);
|
||||
|
||||
/// <summary>天气信息</summary>
|
||||
public record WeatherInfo(
|
||||
string City, string Region, string Text, string Icon,
|
||||
decimal TempC, decimal FeelsLike, decimal Humidity, string Wind,
|
||||
decimal TodayMin, decimal TodayMax, DateTime UpdateTime, string Source);
|
||||
@@ -0,0 +1,30 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record FarmerDto(
|
||||
int Id, string Name, string IdCard, string Gender, string FarmerType, string Phone,
|
||||
string Province, string County, string Township, string Village, string GroupName,
|
||||
string Address, string BankName, string BankAccount, int CreditScore,
|
||||
string IdCardFrontUrl, string IdCardBackUrl, string AvatarUrl,
|
||||
string Status, string Notes, DateTime CreatedAt);
|
||||
|
||||
public record FarmerSaveRequest(
|
||||
string Name, string IdCard, string Gender, string Phone,
|
||||
string Province, string County, string Township, string Village, string GroupName,
|
||||
string Address, string BankName, string BankAccount, int CreditScore = 80,
|
||||
string FarmerType = "农户",
|
||||
string IdCardFrontUrl = "", string IdCardBackUrl = "", string AvatarUrl = "",
|
||||
string Status = "Active", string Notes = "");
|
||||
|
||||
public static class FarmerMappers
|
||||
{
|
||||
public static FarmerDto ToDto(this Farmer f) => new(
|
||||
f.Id, f.Name, f.IdCard, f.Gender, f.FarmerType, f.Phone,
|
||||
f.Province, f.County, f.Township, f.Village, f.GroupName,
|
||||
f.Address, f.BankName, f.BankAccount, f.CreditScore,
|
||||
f.IdCardFrontUrl, f.IdCardBackUrl, f.AvatarUrl,
|
||||
f.Status.ToString(), f.Notes, f.CreatedAt);
|
||||
|
||||
public static FarmerDto[] ToDtos(this IEnumerable<Farmer> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record InvoiceDto(
|
||||
int Id, string InvoiceNo, string? BatchNo, string InvoiceKind,
|
||||
int FarmerId, string FarmerName, string? FarmerIdCard,
|
||||
int PurchaserOrgId, string PurchaserOrgName, int? PurchaseOrderId, string? OrderNo,
|
||||
int ProductId, string ProductName, decimal Quantity, string Unit, decimal UnitPrice,
|
||||
decimal Amount, decimal TaxRate, decimal TaxAmount, DateTime IssueDate,
|
||||
string Status, string? OperatorName, DateTime? ReversedAt, string ReverseReason, DateTime CreatedAt);
|
||||
|
||||
/// <summary>开具销售发票:可指定收购单,也可手工录入</summary>
|
||||
public record InvoiceCreateRequest(
|
||||
int FarmerId, int PurchaserOrgId, int ProductId, int? PurchaseOrderId,
|
||||
decimal Quantity, string Unit, decimal UnitPrice, decimal Amount,
|
||||
decimal TaxRate = 0, string InvoiceKind = "销售发票");
|
||||
|
||||
/// <summary>发票作废</summary>
|
||||
public record InvoiceReverseRequest(string Reason);
|
||||
|
||||
/// <summary>开票结果回填:Status 取值 Issued/Failed/Abnormal</summary>
|
||||
public record InvoiceResultRequest(string Status, string? Message = null);
|
||||
|
||||
/// <summary>收购单维度开票状态行(反向开票清单数据源:过磅称重有效收购单)</summary>
|
||||
public record OrderInvoiceDto(
|
||||
int Id, string OrderNo, DateTime? WeighOutAt,
|
||||
int FarmerId, string FarmerName, string? FarmerIdCard,
|
||||
int ProductId, string ProductName, string Unit, decimal NetWeight, decimal UnitPrice, decimal Amount,
|
||||
int PurchaserOrgId, string PurchaserOrgName, string? PurchaserTaxNo,
|
||||
int? InvoiceId, string? InvoiceNo, string? BatchNo, string InvoiceStatus, DateTime? IssueDate);
|
||||
|
||||
/// <summary>创建开票批次(批量开票)</summary>
|
||||
public record CreateBatchRequest(string? From, string? To, long[] OrderIds);
|
||||
|
||||
/// <summary>开票批次进度</summary>
|
||||
public record BatchProgressDto(string BatchNo, int Total, int Pending, int Issued, int Failed, int Abnormal, bool Done);
|
||||
|
||||
public static class InvoiceMappers
|
||||
{
|
||||
public static InvoiceDto ToDto(this Invoice i) => new(
|
||||
i.Id, i.InvoiceNo, i.BatchNo, i.InvoiceKind,
|
||||
i.FarmerId, i.Farmer?.Name ?? "", i.Farmer?.IdCard ?? "",
|
||||
i.PurchaserOrgId, i.PurchaserOrg?.Name ?? "", i.PurchaseOrderId, i.PurchaseOrder?.OrderNo,
|
||||
i.ProductId, i.Product?.Name ?? "", i.Quantity, i.Unit, i.UnitPrice,
|
||||
i.Amount, i.TaxRate, i.TaxAmount, i.IssueDate,
|
||||
i.Status.ToString(), i.Operator?.RealName ?? "", i.ReversedAt, i.ReverseReason, i.CreatedAt);
|
||||
|
||||
public static InvoiceDto[] ToDtos(this IEnumerable<Invoice> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record OrgDto(
|
||||
int Id, string Type, string Name, int? ParentId, string? ParentName,
|
||||
string ContactPerson, string Phone, string Address, string TaxNo, bool IsActive, DateTime CreatedAt);
|
||||
|
||||
public record OrgSaveRequest(
|
||||
string Type, string Name, int? ParentId, string ContactPerson,
|
||||
string Phone, string Address, string TaxNo, bool IsActive = true);
|
||||
|
||||
public static class OrgMappers
|
||||
{
|
||||
public static OrgDto ToDto(this Organization o) => new(
|
||||
o.Id, o.Type.ToString(), o.Name, o.ParentId, o.Parent?.Name,
|
||||
o.ContactPerson, o.Phone, o.Address, o.TaxNo, o.IsActive, o.CreatedAt);
|
||||
|
||||
public static OrgDto[] ToDtos(this IEnumerable<Organization> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record PaymentDto(
|
||||
int Id, string PayNo, int? PurchaseOrderId, string? OrderNo,
|
||||
int FarmerId, string FarmerName, decimal Amount, string Method,
|
||||
string Status, string TradeNo, string? OperatorName, DateTime? PaidAt,
|
||||
string Notes, DateTime CreatedAt);
|
||||
|
||||
/// <summary>发起支付(按收购单结算,可为空表示对农户批量结算)</summary>
|
||||
public record PaymentCreateRequest(
|
||||
int? PurchaseOrderId, int FarmerId, decimal Amount, string Method, string Notes = "");
|
||||
|
||||
/// <summary>确认支付结果:Status 缺省视为成功,可选 Success/Failed/Abnormal</summary>
|
||||
public record PaymentConfirmRequest(string? TradeNo = null, string? Status = null, string? Message = null);
|
||||
|
||||
public static class PaymentMappers
|
||||
{
|
||||
public static PaymentDto ToDto(this PaymentRecord p) => new(
|
||||
p.Id, p.PayNo, p.PurchaseOrderId, p.PurchaseOrder?.OrderNo,
|
||||
p.FarmerId, p.Farmer?.Name ?? "", p.Amount, p.Method.ToString(),
|
||||
p.Status.ToString(), p.TradeNo, p.Operator?.RealName ?? "",
|
||||
p.PaidAt, p.Notes, p.CreatedAt);
|
||||
|
||||
public static PaymentDto[] ToDtos(this IEnumerable<PaymentRecord> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record ProductDto(
|
||||
int Id, string Name, string Category, string Unit, string Spec,
|
||||
decimal Price, string Status, DateTime CreatedAt);
|
||||
|
||||
public record ProductSaveRequest(
|
||||
string Name, string Category, string Unit, string Spec, decimal Price,
|
||||
string Status = "Active");
|
||||
|
||||
public static class ProductMappers
|
||||
{
|
||||
public static ProductDto ToDto(this Product p) => new(
|
||||
p.Id, p.Name, p.Category, p.Unit, p.Spec, p.Price, p.Status.ToString(), p.CreatedAt);
|
||||
|
||||
public static ProductDto[] ToDtos(this IEnumerable<Product> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record PurchaseDto(
|
||||
int Id, string OrderNo, int FarmerId, string FarmerName, string? FarmerPhone,
|
||||
int ProductId, string ProductName, string Category, int PurchaserOrgId, string PurchaserOrgName,
|
||||
string Grade, string Unit, decimal UnitPrice, decimal GrossWeight, decimal TareWeight,
|
||||
decimal NetWeight, decimal Amount, int WeighCount, string Status,
|
||||
string? OperatorName, DateTime WeighInAt, DateTime? WeighOutAt, string Notes, DateTime CreatedAt);
|
||||
|
||||
/// <summary>新建收购单(第一次过磅:称毛重)</summary>
|
||||
public record PurchaseCreateRequest(
|
||||
int FarmerId, int ProductId, int PurchaserOrgId, string Grade,
|
||||
decimal GrossWeight, string Unit = "公斤", decimal UnitPrice = 0, string Notes = "");
|
||||
|
||||
/// <summary>回皮完成(第二次过磅:称皮重,计算净重与金额)</summary>
|
||||
public record PurchaseTareRequest(decimal TareWeight);
|
||||
|
||||
public static class PurchaseMappers
|
||||
{
|
||||
public static PurchaseDto ToDto(this PurchaseOrder p) => new(
|
||||
p.Id, p.OrderNo,
|
||||
p.FarmerId, p.Farmer?.Name ?? "", p.Farmer?.Phone ?? "",
|
||||
p.ProductId, p.Product?.Name ?? "", p.Product?.Category ?? "",
|
||||
p.PurchaserOrgId, p.PurchaserOrg?.Name ?? "",
|
||||
p.Grade, p.Unit, p.UnitPrice, p.GrossWeight, p.TareWeight,
|
||||
p.NetWeight, p.Amount, p.WeighCount, p.Status.ToString(),
|
||||
p.Operator?.RealName ?? "", p.WeighInAt, p.WeighOutAt, p.Notes, p.CreatedAt);
|
||||
|
||||
public static PurchaseDto[] ToDtos(this IEnumerable<PurchaseOrder> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
/// <summary>汇总明细行</summary>
|
||||
public record SummaryRow(string Key, string Label, decimal Value, int Count);
|
||||
|
||||
/// <summary>收购汇总(按维度:日/月/品种/农户/站点)</summary>
|
||||
public record PurchaseSummaryResult(
|
||||
string Dimension, DateTime From, DateTime To,
|
||||
decimal TotalAmount, decimal TotalNetWeight, int TotalCount, SummaryRow[] Rows);
|
||||
|
||||
/// <summary>付款统计</summary>
|
||||
public record PaymentSummaryResult(
|
||||
DateTime From, DateTime To, decimal TotalPaid, decimal TotalPending, int PayCount,
|
||||
SummaryRow[] ByMethod, SummaryRow[] ByStatus);
|
||||
|
||||
/// <summary>开票统计</summary>
|
||||
public record InvoiceSummaryResult(
|
||||
DateTime From, DateTime To, decimal TotalAmount, decimal TotalTax, int IssueCount,
|
||||
SummaryRow[] ByProduct, SummaryRow[] ByStatus);
|
||||
@@ -0,0 +1,20 @@
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Dtos;
|
||||
|
||||
public record UserDto(
|
||||
int Id, string Username, string RealName, string Phone, string Role,
|
||||
int? OrgId, string? OrgName, bool IsActive, DateTime? LastLoginAt, DateTime CreatedAt);
|
||||
|
||||
public record UserSaveRequest(
|
||||
string Username, string RealName, string Phone, string Role,
|
||||
int? OrgId, bool IsActive = true, string? Password = null);
|
||||
|
||||
public static class UserMappers
|
||||
{
|
||||
public static UserDto ToDto(this User u) => new(
|
||||
u.Id, u.Username, u.RealName, u.Phone, u.Role.ToString(),
|
||||
u.OrgId, u.Org?.Name, u.IsActive, u.LastLoginAt, u.CreatedAt);
|
||||
|
||||
public static UserDto[] ToDtos(this IEnumerable<User> items) => items.Select(ToDto).ToArray();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>组织类型:收购公司 / 收购站 / 收购个体</summary>
|
||||
public enum OrgType
|
||||
{
|
||||
Company, // 收购公司
|
||||
Station, // 收购站
|
||||
Individual // 收购个体
|
||||
}
|
||||
|
||||
/// <summary>用户角色</summary>
|
||||
public enum UserRole
|
||||
{
|
||||
SuperAdmin, // 系统管理员
|
||||
CompanyAdmin, // 公司管理员
|
||||
StationStaff, // 收购站员工
|
||||
Individual // 收购个体
|
||||
}
|
||||
|
||||
/// <summary>农户状态</summary>
|
||||
public enum FarmerStatus
|
||||
{
|
||||
Active, // 正常
|
||||
Frozen // 冻结
|
||||
}
|
||||
|
||||
/// <summary>收购单状态</summary>
|
||||
public enum PurchaseStatus
|
||||
{
|
||||
Weighing, // 过磅中(已称毛重,待回皮)
|
||||
Completed, // 已完成
|
||||
Cancelled // 已作废
|
||||
}
|
||||
|
||||
/// <summary>支付方式</summary>
|
||||
public enum PayMethod
|
||||
{
|
||||
Wechat, // 微信
|
||||
Alipay, // 支付宝
|
||||
BankTransfer, // 银行转账
|
||||
Cash // 现金
|
||||
}
|
||||
|
||||
/// <summary>支付状态</summary>
|
||||
public enum PayStatus
|
||||
{
|
||||
Pending, // 待支付
|
||||
Success, // 支付成功
|
||||
Failed, // 支付失败
|
||||
Abnormal, // 支付异常
|
||||
Refunded // 已退款
|
||||
}
|
||||
|
||||
/// <summary>发票状态</summary>
|
||||
public enum InvoiceStatus
|
||||
{
|
||||
Pending, // 待开票
|
||||
Issued, // 开票成功
|
||||
Failed, // 开票失败
|
||||
Abnormal, // 开票异常
|
||||
Reversed // 已作废
|
||||
}
|
||||
|
||||
/// <summary>品种状态</summary>
|
||||
public enum ProductStatus
|
||||
{
|
||||
Active, // 启用
|
||||
Disabled // 停用
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>农户档案</summary>
|
||||
public class Farmer
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>身份证号(唯一)</summary>
|
||||
public string IdCard { get; set; } = string.Empty;
|
||||
|
||||
public string Gender { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>农户类型:农户/个体户/合作社/经营集体/公司</summary>
|
||||
public string FarmerType { get; set; } = "农户";
|
||||
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>所在省</summary>
|
||||
public string Province { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>所在县(区)</summary>
|
||||
public string County { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>所在乡镇(街道)</summary>
|
||||
public string Township { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>所在村/合作社</summary>
|
||||
public string Village { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>所在组</summary>
|
||||
public string GroupName { get; set; } = string.Empty;
|
||||
|
||||
public string Address { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>开户银行</summary>
|
||||
public string BankName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>银行卡号(付款用)</summary>
|
||||
public string BankAccount { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>身份证正面附件地址(阿里云 OSS / 本地存储 key 或 URL)</summary>
|
||||
public string IdCardFrontUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>身份证反面附件地址(阿里云 OSS / 本地存储 key 或 URL)</summary>
|
||||
public string IdCardBackUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>农户头像附件地址(摄像头/手机采集,未来用于人脸识别收购)</summary>
|
||||
public string AvatarUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>信用评分(0-100)</summary>
|
||||
public int CreditScore { get; set; } = 80;
|
||||
|
||||
public FarmerStatus Status { get; set; } = FarmerStatus.Active;
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>农产品反向开票(收购发票:收购方向农户开具)</summary>
|
||||
public class Invoice
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>发票号码(如 FP20260812-0001)</summary>
|
||||
public string InvoiceNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>开票批次号(批量开票时同一批共用)</summary>
|
||||
public string BatchNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>票种:农产品收购发票</summary>
|
||||
public string InvoiceKind { get; set; } = "农产品收购发票";
|
||||
|
||||
/// <summary>销售方(农户)</summary>
|
||||
public int FarmerId { get; set; }
|
||||
|
||||
public Farmer? Farmer { get; set; }
|
||||
|
||||
/// <summary>收购方(公司/站/个体)</summary>
|
||||
public int PurchaserOrgId { get; set; }
|
||||
|
||||
public Organization? PurchaserOrg { get; set; }
|
||||
|
||||
/// <summary>关联收购单(可空)</summary>
|
||||
public int? PurchaseOrderId { get; set; }
|
||||
|
||||
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||
|
||||
/// <summary>品名(冗余,便于发票展示)</summary>
|
||||
public int ProductId { get; set; }
|
||||
|
||||
public Product? Product { get; set; }
|
||||
|
||||
/// <summary>数量</summary>
|
||||
public decimal Quantity { get; set; }
|
||||
|
||||
public string Unit { get; set; } = "公斤";
|
||||
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>价税合计(元)</summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>税率(%),农产品收购发票免税为 0</summary>
|
||||
public decimal TaxRate { get; set; } = 0m;
|
||||
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
public DateTime IssueDate { get; set; } = DateTime.Now;
|
||||
|
||||
public InvoiceStatus Status { get; set; } = InvoiceStatus.Issued;
|
||||
|
||||
/// <summary>开票人</summary>
|
||||
public int? OperatorId { get; set; }
|
||||
|
||||
public User? Operator { get; set; }
|
||||
|
||||
public DateTime? ReversedAt { get; set; }
|
||||
|
||||
public string ReverseReason { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>组织(收购公司 / 收购站 / 收购个体)</summary>
|
||||
public class Organization
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public OrgType Type { get; set; }
|
||||
|
||||
/// <summary>组织名称</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>上级组织(收购站归属的公司)</summary>
|
||||
public int? ParentId { get; set; }
|
||||
|
||||
public Organization? Parent { get; set; }
|
||||
|
||||
public string ContactPerson { get; set; } = string.Empty;
|
||||
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
public string Address { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>税号(公司/个体开票用)</summary>
|
||||
public string TaxNo { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>电子支付记录(农户货款结算)</summary>
|
||||
public class PaymentRecord
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>付款单号(如 ZF20260812-0001)</summary>
|
||||
public string PayNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>关联收购单(可为空:批量结算)</summary>
|
||||
public int? PurchaseOrderId { get; set; }
|
||||
|
||||
public PurchaseOrder? PurchaseOrder { get; set; }
|
||||
|
||||
/// <summary>收购方组织(批量结算时也记录归属)</summary>
|
||||
public int? PurchaserOrgId { get; set; }
|
||||
|
||||
public Organization? PurchaserOrg { get; set; }
|
||||
|
||||
public int FarmerId { get; set; }
|
||||
|
||||
public Farmer? Farmer { get; set; }
|
||||
|
||||
/// <summary>付款金额(元)</summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
public PayMethod Method { get; set; } = PayMethod.BankTransfer;
|
||||
|
||||
public PayStatus Status { get; set; } = PayStatus.Pending;
|
||||
|
||||
/// <summary>外部交易流水号</summary>
|
||||
public string TradeNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>经办人</summary>
|
||||
public int? OperatorId { get; set; }
|
||||
|
||||
public User? Operator { get; set; }
|
||||
|
||||
public DateTime? PaidAt { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>农产品品种</summary>
|
||||
public class Product
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>品种名称(如:小麦、玉米)</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>品类(粮食/蔬菜/水果/经济作物/畜禽等)</summary>
|
||||
public string Category { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>计量单位(公斤/吨/斤)</summary>
|
||||
public string Unit { get; set; } = "公斤";
|
||||
|
||||
/// <summary>规格说明</summary>
|
||||
public string Spec { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>参考单价(元/单位)</summary>
|
||||
public decimal Price { get; set; }
|
||||
|
||||
public ProductStatus Status { get; set; } = ProductStatus.Active;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>收购单(过磅称重单)</summary>
|
||||
public class PurchaseOrder
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>收购单号(如 CG20260812-0001)</summary>
|
||||
public string OrderNo { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>农户</summary>
|
||||
public int FarmerId { get; set; }
|
||||
|
||||
public Farmer? Farmer { get; set; }
|
||||
|
||||
/// <summary>收购品种</summary>
|
||||
public int ProductId { get; set; }
|
||||
|
||||
public Product? Product { get; set; }
|
||||
|
||||
/// <summary>收购方组织(公司/站/个体)</summary>
|
||||
public int PurchaserOrgId { get; set; }
|
||||
|
||||
public Organization? PurchaserOrg { get; set; }
|
||||
|
||||
/// <summary>等级(一等/二等/三等)</summary>
|
||||
public string Grade { get; set; } = string.Empty;
|
||||
|
||||
public string Unit { get; set; } = "公斤";
|
||||
|
||||
/// <summary>单价(元/单位)</summary>
|
||||
public decimal UnitPrice { get; set; }
|
||||
|
||||
/// <summary>毛重(kg)</summary>
|
||||
public decimal GrossWeight { get; set; }
|
||||
|
||||
/// <summary>皮重(kg)</summary>
|
||||
public decimal TareWeight { get; set; }
|
||||
|
||||
/// <summary>净重(kg)= 毛重 - 皮重</summary>
|
||||
public decimal NetWeight { get; set; }
|
||||
|
||||
/// <summary>金额(元)= 净重 × 单价</summary>
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
/// <summary>过磅次数:1=已称毛重,2=已回皮完成</summary>
|
||||
public int WeighCount { get; set; }
|
||||
|
||||
public PurchaseStatus Status { get; set; } = PurchaseStatus.Weighing;
|
||||
|
||||
/// <summary>经办人</summary>
|
||||
public int? OperatorId { get; set; }
|
||||
|
||||
public User? Operator { get; set; }
|
||||
|
||||
/// <summary>第一次过磅时间(进场称毛重)</summary>
|
||||
public DateTime WeighInAt { get; set; } = DateTime.Now;
|
||||
|
||||
/// <summary>回皮时间</summary>
|
||||
public DateTime? WeighOutAt { get; set; }
|
||||
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 行政区划(来自 AreaCity 全国省市区乡镇四级公开数据,导入 Regions 表)。
|
||||
/// deep: 0=省 1=市(地级/直辖市虚拟层级) 2=县(区/县级市) 3=乡镇(镇/乡/街道)
|
||||
/// </summary>
|
||||
public class Region
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>上级 Id(省为 0)</summary>
|
||||
public int Pid { get; set; }
|
||||
|
||||
/// <summary>层级深度 0~3</summary>
|
||||
public int Deep { get; set; }
|
||||
|
||||
/// <summary>精简名称(如 武汉)</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>完整名称(如 武汉市)</summary>
|
||||
public string ExtName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>完整拼音(如 wu han)</summary>
|
||||
public string Pinyin { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>拼音首字母前缀(如 w)</summary>
|
||||
public string PinyinPrefix { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>热度(值越大排序越靠前,用于常用省份/地区优先展示,默认 0)</summary>
|
||||
public int Hot { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace AgriculturalPlatform.Api.Models;
|
||||
|
||||
/// <summary>系统用户</summary>
|
||||
public class User
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public string Username { get; set; } = string.Empty;
|
||||
|
||||
public string PasswordHash { get; set; } = string.Empty;
|
||||
|
||||
public string RealName { get; set; } = string.Empty;
|
||||
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
public UserRole Role { get; set; }
|
||||
|
||||
/// <summary>所属组织(公司管理员→公司;站员工→站;个体→个体组织)</summary>
|
||||
public int? OrgId { get; set; }
|
||||
|
||||
public Organization? Org { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public DateTime? LastLoginAt { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.Now;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Text;
|
||||
using System.Text.Json.Serialization;
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ---------- 注册 GBK 等编码(天气 IP 定位接口使用) ----------
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
|
||||
// ---------- 在线 MySQL 数据库 ----------
|
||||
var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||
?? throw new InvalidOperationException("未配置数据库连接字符串 ConnectionStrings:Default");
|
||||
|
||||
var serverVersion = new MySqlServerVersion(new Version(
|
||||
builder.Configuration.GetValue("MySql:Major", 8),
|
||||
builder.Configuration.GetValue("MySql:Minor", 0),
|
||||
builder.Configuration.GetValue("MySql:Build", 36)));
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseMySql(connectionString, serverVersion, mysql =>
|
||||
{
|
||||
mysql.EnableRetryOnFailure(3);
|
||||
mysql.CommandTimeout(30);
|
||||
}));
|
||||
|
||||
// ---------- JWT 认证 ----------
|
||||
var jwtKey = builder.Configuration["Jwt:Key"] ?? "AgriculturalPlatform-Default-Jwt-Secret-Key-2026!";
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = builder.Configuration["Jwt:Issuer"] ?? "AgriculturalPlatform",
|
||||
ValidAudience = builder.Configuration["Jwt:Audience"] ?? "AgriculturalPlatform.Client",
|
||||
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey))
|
||||
};
|
||||
options.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx =>
|
||||
{
|
||||
// 兼容前端直接携带 token 的查询参数方式
|
||||
if (string.IsNullOrEmpty(ctx.Token)
|
||||
&& ctx.Request.Query.TryGetValue("access_token", out var token))
|
||||
{
|
||||
ctx.Token = token;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// ---------- JSON:枚举序列化为字符串、中文不被转义 ----------
|
||||
builder.Services.Configure<JsonOptions>(o =>
|
||||
{
|
||||
o.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
o.SerializerOptions.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping;
|
||||
});
|
||||
|
||||
// ---------- 业务服务 ----------
|
||||
builder.Services.AddSingleton<JwtService>();
|
||||
builder.Services.AddScoped<CurrentUserService>();
|
||||
builder.Services.AddScoped<DataScopeService>();
|
||||
builder.Services.AddScoped<NumberGenerator>();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
|
||||
// ---------- 附件存储(本地为准,OSS 配置好后切换) ----------
|
||||
builder.Services.AddSingleton<LocalFileStorage>();
|
||||
builder.Services.AddSingleton<OssFileStorage>();
|
||||
builder.Services.AddSingleton<IFileStorage>(sp =>
|
||||
sp.GetRequiredService<IConfiguration>().GetValue("Storage:UseOss", false)
|
||||
? sp.GetRequiredService<OssFileStorage>()
|
||||
: sp.GetRequiredService<LocalFileStorage>());
|
||||
|
||||
// ---------- OCR 识别(默认占位,接入阿里云/百度 OCR 时替换实现) ----------
|
||||
builder.Services.AddSingleton<IOcrService, DisabledOcrService>();
|
||||
|
||||
// ---------- 手机传图上传会话 ----------
|
||||
builder.Services.AddSingleton<MobileUploadSessionStore>();
|
||||
|
||||
// ---------- 天气服务(调用第三方天气接口) ----------
|
||||
builder.Services.AddHttpClient("weather");
|
||||
|
||||
// ---------- 前端(Vue 开发服务器)跨域 ----------
|
||||
var frontendUrl = builder.Configuration["FrontendUrl"] ?? "http://localhost:5173";
|
||||
builder.Services.AddCors(o => o.AddPolicy("frontend", p =>
|
||||
p.WithOrigins(frontendUrl.Split(';')).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ---------- 初始化数据库(自动建库建表 + 种子数据) ----------
|
||||
try
|
||||
{
|
||||
using var scope = app.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
await SchemaMigrator.EnsureColumnsAsync(db);
|
||||
await SchemaMigrator.EnsureRegionsTableAsync(db);
|
||||
DbSeeder.Seed(db);
|
||||
app.Logger.LogInformation("数据库初始化完成");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
app.Logger.LogError(ex, "数据库初始化失败,请检查 ConnectionStrings:Default 配置");
|
||||
}
|
||||
|
||||
app.UseCors("frontend");
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
// 附件与手机上传页面(wwwroot)
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapOpenApi();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://0.0.0.0:5246",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Security.Claims;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>从 JWT 声明中解析的当前登录用户</summary>
|
||||
public class CurrentUser
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Username { get; init; } = string.Empty;
|
||||
public string RealName { get; init; } = string.Empty;
|
||||
public UserRole Role { get; init; }
|
||||
public int? OrgId { get; init; }
|
||||
|
||||
public bool IsSuperAdmin => Role == UserRole.SuperAdmin;
|
||||
}
|
||||
|
||||
public class CurrentUserService(IHttpContextAccessor accessor)
|
||||
{
|
||||
public CurrentUser? Get()
|
||||
{
|
||||
var principal = accessor.HttpContext?.User;
|
||||
if (principal is null) return null;
|
||||
var idClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!int.TryParse(idClaim, out var id)) return null;
|
||||
|
||||
Enum.TryParse<UserRole>(principal.FindFirstValue(ClaimTypes.Role), out var role);
|
||||
int? orgId = int.TryParse(principal.FindFirstValue("orgId"), out var o) ? o : null;
|
||||
|
||||
return new CurrentUser
|
||||
{
|
||||
Id = id,
|
||||
Username = principal.FindFirstValue(ClaimTypes.Name) ?? "",
|
||||
RealName = principal.FindFirstValue("realName") ?? "",
|
||||
Role = role,
|
||||
OrgId = orgId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 数据权限:根据当前用户角色计算其可见的组织范围
|
||||
/// - 系统管理员:全部
|
||||
/// - 公司管理员:本公司 + 旗下收购站
|
||||
/// - 收购站员工:本站
|
||||
/// - 收购个体:本人
|
||||
/// </summary>
|
||||
public class DataScopeService(AppDbContext db)
|
||||
{
|
||||
/// <summary>返回可见组织 ID 集合;null 表示全部可见</summary>
|
||||
public async Task<HashSet<int>?> GetVisibleOrgIdsAsync(CurrentUser? user)
|
||||
{
|
||||
if (user is null) return new HashSet<int>();
|
||||
if (user.IsSuperAdmin) return null;
|
||||
|
||||
var ids = new HashSet<int>();
|
||||
switch (user.Role)
|
||||
{
|
||||
case UserRole.CompanyAdmin:
|
||||
if (user.OrgId.HasValue)
|
||||
{
|
||||
ids.Add(user.OrgId.Value);
|
||||
var stationIds = await db.Organizations
|
||||
.Where(o => o.ParentId == user.OrgId && o.Type == OrgType.Station)
|
||||
.Select(o => o.Id).ToListAsync();
|
||||
ids.UnionWith(stationIds);
|
||||
}
|
||||
break;
|
||||
case UserRole.StationStaff:
|
||||
case UserRole.Individual:
|
||||
if (user.OrgId.HasValue) ids.Add(user.OrgId.Value);
|
||||
break;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>已保存文件的存储 key 与访问地址</summary>
|
||||
public record StoredFile(string Key, string Url);
|
||||
|
||||
/// <summary>
|
||||
/// 附件文件存储抽象。
|
||||
/// 当前默认实现为本地存储(wwwroot/uploads);
|
||||
/// 阿里云 OSS 授权验证配置好后(appsettings.json 的 Oss 节点),
|
||||
/// 将 Storage:UseOss 设为 true 即可切换到 OSS。
|
||||
/// </summary>
|
||||
public interface IFileStorage
|
||||
{
|
||||
/// <summary>保存文件,返回存储 key 与访问 URL</summary>
|
||||
Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType);
|
||||
|
||||
/// <summary>按 key 删除文件(本地/OSS 通用)</summary>
|
||||
Task DeleteAsync(string key);
|
||||
}
|
||||
|
||||
/// <summary>本地磁盘存储(wwwroot/uploads),URL 即相对路径,由静态文件中间件直接访问</summary>
|
||||
public sealed class LocalFileStorage(IWebHostEnvironment env) : IFileStorage
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
env.WebRootPath ?? Path.Combine(env.ContentRootPath, "wwwroot"), "uploads");
|
||||
|
||||
public async Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
|
||||
{
|
||||
var dateDir = DateTime.Now.ToString("yyyyMM");
|
||||
var dir = Path.Combine(_root, subDir, dateDir);
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
var ext = Path.GetExtension(fileName);
|
||||
if (string.IsNullOrWhiteSpace(ext)) ext = ".jpg";
|
||||
if (ext.Length > 10) ext = ".jpg";
|
||||
var name = $"{Guid.NewGuid():N}{ext}";
|
||||
var full = Path.Combine(dir, name);
|
||||
|
||||
await using var fs = File.Create(full);
|
||||
await stream.CopyToAsync(fs);
|
||||
|
||||
var key = $"/uploads/{subDir}/{dateDir}/{name}";
|
||||
return new StoredFile(key, key);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(string key)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("/uploads/")) return Task.CompletedTask;
|
||||
try
|
||||
{
|
||||
var full = Path.Combine(_root, key.TrimStart('/').Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(full)) File.Delete(full);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 忽略删除失败
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 阿里云 OSS 存储。授权验证(AccessKeyId/Secret、Endpoint、Bucket)后期配置提供;
|
||||
/// 未配置前调用会抛出 InvalidOperationException,由接口统一转为友好提示。
|
||||
/// </summary>
|
||||
public sealed class OssFileStorage(IConfiguration cfg) : IFileStorage
|
||||
{
|
||||
public bool IsConfigured =>
|
||||
!string.IsNullOrWhiteSpace(cfg["Oss:AccessKeyId"])
|
||||
&& !string.IsNullOrWhiteSpace(cfg["Oss:AccessKeySecret"])
|
||||
&& !string.IsNullOrWhiteSpace(cfg["Oss:Bucket"]);
|
||||
|
||||
public Task<StoredFile> SaveAsync(string subDir, Stream stream, string fileName, string contentType)
|
||||
=> throw new InvalidOperationException(
|
||||
"阿里云 OSS 尚未配置授权验证,请在 appsettings.json 的 Oss 节点填写 AccessKeyId / AccessKeySecret / Endpoint / Bucket 后重启服务");
|
||||
|
||||
public Task DeleteAsync(string key)
|
||||
=> throw new InvalidOperationException("阿里云 OSS 尚未配置授权验证,暂不支持删除");
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using AgriculturalPlatform.Api.Models;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
public class JwtService(IConfiguration config)
|
||||
{
|
||||
private readonly string _key = config["Jwt:Key"] ?? "AgriculturalPlatform-Default-Jwt-Secret-Key-2026!";
|
||||
private readonly string _issuer = config["Jwt:Issuer"] ?? "AgriculturalPlatform";
|
||||
private readonly string _audience = config["Jwt:Audience"] ?? "AgriculturalPlatform.Client";
|
||||
|
||||
public string Generate(User user)
|
||||
{
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
|
||||
new(ClaimTypes.Name, user.Username),
|
||||
new(ClaimTypes.Role, user.Role.ToString()),
|
||||
new("realName", user.RealName),
|
||||
new("orgId", user.OrgId?.ToString() ?? "")
|
||||
};
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_key));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _issuer,
|
||||
audience: _audience,
|
||||
claims: claims,
|
||||
expires: DateTime.Now.AddDays(7),
|
||||
signingCredentials: creds);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>手机传图上传会话(内存存储,10 分钟过期)</summary>
|
||||
public sealed class MobileUploadSession
|
||||
{
|
||||
public string Code { get; init; } = string.Empty;
|
||||
public DateTime CreatedAt { get; init; } = DateTime.Now;
|
||||
public StoredFile? Front { get; set; }
|
||||
public StoredFile? Back { get; set; }
|
||||
public IdCardOcrResult? FrontOcr { get; set; }
|
||||
public IdCardOcrResult? BackOcr { get; set; }
|
||||
public bool Done { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>手机传图上传会话存储(单机内存版,后续可换 Redis)</summary>
|
||||
public sealed class MobileUploadSessionStore
|
||||
{
|
||||
private static readonly TimeSpan Expire = TimeSpan.FromMinutes(10);
|
||||
private readonly ConcurrentDictionary<string, MobileUploadSession> _sessions = new();
|
||||
|
||||
public string Create()
|
||||
{
|
||||
Cleanup();
|
||||
var code = Guid.NewGuid().ToString("N")[..8].ToUpperInvariant();
|
||||
_sessions[code] = new MobileUploadSession { Code = code };
|
||||
return code;
|
||||
}
|
||||
|
||||
public MobileUploadSession? Get(string code)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code)) return null;
|
||||
return _sessions.TryGetValue(code.Trim().ToUpperInvariant(), out var s) ? s : null;
|
||||
}
|
||||
|
||||
public void Remove(string code) => _sessions.TryRemove(code, out _);
|
||||
|
||||
private void Cleanup()
|
||||
{
|
||||
var now = DateTime.Now;
|
||||
foreach (var kv in _sessions)
|
||||
{
|
||||
if (now - kv.Value.CreatedAt > Expire) _sessions.TryRemove(kv.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using AgriculturalPlatform.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>业务单号生成:前缀 + 日期 + 当日序号(如 CG20260812-0001)</summary>
|
||||
public class NumberGenerator(AppDbContext db)
|
||||
{
|
||||
public async Task<string> NextAsync(string prefix, DateOnly? date = null)
|
||||
{
|
||||
var day = date ?? DateOnly.FromDateTime(DateTime.Now);
|
||||
var seq = 1;
|
||||
try
|
||||
{
|
||||
// 从各单据表按当日已存在数量生成序号
|
||||
var todayStart = day.ToDateTime(TimeOnly.MinValue);
|
||||
var todayEnd = todayStart.AddDays(1);
|
||||
var count = prefix switch
|
||||
{
|
||||
"CG" => await db.PurchaseOrders.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
|
||||
"ZF" => await db.PaymentRecords.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
|
||||
"FP" => await db.Invoices.CountAsync(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd),
|
||||
"PK" => await db.Invoices
|
||||
.Where(x => x.CreatedAt >= todayStart && x.CreatedAt < todayEnd && x.BatchNo != "")
|
||||
.Select(x => x.BatchNo).Distinct().CountAsync(),
|
||||
_ => 0
|
||||
};
|
||||
seq = count + 1;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 数据库不可用时使用随机序号
|
||||
}
|
||||
|
||||
return $"{prefix}{day:yyyyMMdd}-{seq:D4}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
namespace AgriculturalPlatform.Api.Services;
|
||||
|
||||
/// <summary>身份证 OCR 识别结果(未识别成功时 Success=false 并给出原因)</summary>
|
||||
public record IdCardOcrResult(
|
||||
string Name = "",
|
||||
string IdCard = "",
|
||||
string Gender = "",
|
||||
string Address = "",
|
||||
string BankName = "",
|
||||
string BankAccount = "",
|
||||
bool Success = false,
|
||||
string Message = "");
|
||||
|
||||
/// <summary>
|
||||
/// OCR 识别服务抽象。当前默认实现为 DisabledOcrService(仅占位);
|
||||
/// 后续接入阿里云/百度 OCR 时,实现本接口并在 Program.cs 替换注册即可,调用方无需改动。
|
||||
/// </summary>
|
||||
public interface IOcrService
|
||||
{
|
||||
/// <summary>识别身份证图片,side: front(正面)/ back(反面)</summary>
|
||||
Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg");
|
||||
}
|
||||
|
||||
/// <summary>默认 OCR 实现:未配置凭证时给出友好提示,图片仍会正常保存为附件</summary>
|
||||
public sealed class DisabledOcrService(IConfiguration cfg) : IOcrService
|
||||
{
|
||||
public Task<IdCardOcrResult> RecognizeAsync(Stream image, string side, string fileName = "idcard.jpg")
|
||||
{
|
||||
var message = cfg["Ocr:DisabledMessage"]
|
||||
?? "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写";
|
||||
return Task.FromResult(new IdCardOcrResult(Success: false, Message: message));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Default": "server=116.198.221.105;port=3306;database=F8Web;user=f8web;password=r^P%eGC2e2;charset=utf8mb4;SslMode=None;AllowPublicKeyRetrieval=True"
|
||||
},
|
||||
"MySql": {
|
||||
"Major": 8,
|
||||
"Minor": 0,
|
||||
"Build": 36
|
||||
},
|
||||
"Jwt": {
|
||||
"Key": "AgriculturalPlatform-Jwt-Secret-Key-Change-Me-In-Production-2026",
|
||||
"Issuer": "AgriculturalPlatform",
|
||||
"Audience": "AgriculturalPlatform.Client"
|
||||
},
|
||||
"FrontendUrl": "http://localhost:5173",
|
||||
"Server": {
|
||||
"LanPort": 5246
|
||||
},
|
||||
"Storage": {
|
||||
"UseOss": false
|
||||
},
|
||||
"Oss": {
|
||||
"AccessKeyId": "",
|
||||
"AccessKeySecret": "",
|
||||
"Endpoint": "oss-cn-hangzhou.aliyuncs.com",
|
||||
"Bucket": "",
|
||||
"PublicUrl": ""
|
||||
},
|
||||
"Ocr": {
|
||||
"Provider": "none",
|
||||
"DisabledMessage": "未配置 OCR 识别服务(待接入阿里云/百度 OCR 凭证),图片已保存为附件,识别字段请手动填写"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>身份证上传</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||
body { margin: 0; font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: #f5f6fa; color: #2c3e50; }
|
||||
.wrap { max-width: 480px; margin: 0 auto; padding: 24px 16px; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
.tip { color: #7f8c8d; font-size: 13px; margin-bottom: 20px; }
|
||||
.card { background: #fff; border-radius: 12px; padding: 16px; margin-bottom: 14px; }
|
||||
.card h2 { font-size: 14px; margin: 0 0 10px; color: #34495e; }
|
||||
.upload-btn { display: block; width: 100%; padding: 12px; border: 1.5px dashed #b8c6d6;
|
||||
border-radius: 8px; background: #fafcff; color: #5a7a99; font-size: 14px;
|
||||
text-align: center; cursor: pointer; }
|
||||
.upload-btn.done { border-color: #27ae60; color: #27ae60; background: #f0faf4; }
|
||||
.upload-btn.has-img { border-color: #2980b9; color: #2980b9; background: #f0f7fd; }
|
||||
.thumb { margin-top: 10px; }
|
||||
.thumb img { width: 100%; border-radius: 8px; display: block; }
|
||||
input[type=file] { display: none; }
|
||||
.submit { width: 100%; padding: 14px; border: 0; border-radius: 10px; background: #2ecc71;
|
||||
color: #fff; font-size: 17px; font-weight: 600; cursor: pointer; }
|
||||
.submit:disabled { background: #bdc3c7; }
|
||||
#status { margin-top: 14px; padding: 12px; border-radius: 8px; display: none; font-size: 14px; }
|
||||
#status.ok { display: block; background: #f0faf4; color: #27ae60; }
|
||||
#status.err { display: block; background: #fdf0ef; color: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>身份证上传</h1>
|
||||
<p class="tip">请将身份证正面(人像面)、反面(国徽面)清晰拍摄后上传</p>
|
||||
|
||||
<div class="card">
|
||||
<h2>正面(人像面)</h2>
|
||||
<label class="upload-btn" id="frontBtn" for="frontInput">点击拍摄 / 选择正面照</label>
|
||||
<input type="file" id="frontInput" accept="image/*" capture="environment">
|
||||
<div class="thumb" id="frontThumb" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>反面(国徽面)</h2>
|
||||
<label class="upload-btn" id="backBtn" for="backInput">点击拍摄 / 选择反面照</label>
|
||||
<input type="file" id="backInput" accept="image/*" capture="environment">
|
||||
<div class="thumb" id="backThumb" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<button class="submit" id="uploadBtn" disabled>上传</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var code = new URLSearchParams(location.search).get('code') || '';
|
||||
var front = null, back = null;
|
||||
|
||||
function bind(inputId, thumbId, btnId, label) {
|
||||
var input = document.getElementById(inputId);
|
||||
var thumb = document.getElementById(thumbId);
|
||||
var btn = document.getElementById(btnId);
|
||||
input.addEventListener('change', function () {
|
||||
var f = input.files && input.files[0];
|
||||
if (!f) return;
|
||||
label = f; // 保存所选文件
|
||||
btn.classList.add('has-img');
|
||||
btn.textContent = f.name + '(已选,点击可重选)';
|
||||
thumb.style.display = 'block';
|
||||
thumb.innerHTML = '<img src="' + URL.createObjectURL(f) + '">';
|
||||
});
|
||||
input.label = label;
|
||||
return input;
|
||||
}
|
||||
// 用闭包保存文件
|
||||
document.getElementById('frontInput').addEventListener('change', function () {
|
||||
var f = this.files && this.files[0];
|
||||
if (!f) return;
|
||||
front = f;
|
||||
var btn = document.getElementById('frontBtn');
|
||||
btn.classList.add('has-img');
|
||||
btn.textContent = '已选正面照,点击重拍';
|
||||
document.getElementById('frontThumb').style.display = 'block';
|
||||
document.getElementById('frontThumb').innerHTML = '<img src="' + URL.createObjectURL(f) + '">';
|
||||
check();
|
||||
});
|
||||
document.getElementById('backInput').addEventListener('change', function () {
|
||||
var f = this.files && this.files[0];
|
||||
if (!f) return;
|
||||
back = f;
|
||||
var btn = document.getElementById('backBtn');
|
||||
btn.classList.add('has-img');
|
||||
btn.textContent = '已选反面照,点击重拍';
|
||||
document.getElementById('backThumb').style.display = 'block';
|
||||
document.getElementById('backThumb').innerHTML = '<img src="' + URL.createObjectURL(f) + '">';
|
||||
check();
|
||||
});
|
||||
|
||||
function check() {
|
||||
document.getElementById('uploadBtn').disabled = !(front || back);
|
||||
}
|
||||
|
||||
function status(ok, msg) {
|
||||
var el = document.getElementById('status');
|
||||
el.className = ok ? 'ok' : 'err';
|
||||
el.textContent = msg;
|
||||
}
|
||||
|
||||
document.getElementById('uploadBtn').addEventListener('click', async function () {
|
||||
if (!code) { status(false, '二维码缺少参数,已失效,请回电脑端重新生成'); return; }
|
||||
var btn = this;
|
||||
btn.disabled = true;
|
||||
btn.textContent = '上传中…';
|
||||
var fd = new FormData();
|
||||
fd.append('code', code);
|
||||
if (front) fd.append('front', front);
|
||||
if (back) fd.append('back', back);
|
||||
try {
|
||||
var resp = await fetch('/api/uploads/mobile', { method: 'POST', body: fd });
|
||||
var data = await resp.json();
|
||||
if (resp.ok && data.ok) {
|
||||
status(true, '上传成功!请回到电脑端查看识别结果。');
|
||||
btn.textContent = '已上传';
|
||||
} else {
|
||||
status(false, data.message || '上传失败,请重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '上传';
|
||||
}
|
||||
} catch (e) {
|
||||
status(false, '网络异常,请确认电脑端服务已开启(同一 Wi-Fi)后重试');
|
||||
btn.disabled = false;
|
||||
btn.textContent = '上传';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user