新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化

- Region 表新增热度列,热门省份优先显示
- 区县下拉按地级市分组、已录区县优先、支持全省搜索
- 组选择内置一组至二十组
- 银行卡号独立行+粗体预览,开户行独立行
- 新增农户类型:农户/个体户/合作社/经营集体/公司
- 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS
- 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展
- 同名村弹窗提醒(防选错乡镇)
- 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作
- 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
This commit is contained in:
2026-08-13 16:07:35 +08:00
commit c377421a3e
93 changed files with 12934 additions and 0 deletions
@@ -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-weatherIP 定位实时天气) ----------
/// <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;
}
+130
View File
@@ -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>
+3
View File
@@ -0,0 +1,3 @@
<Solution>
<Project Path="AgriculturalPlatform.Api/AgriculturalPlatform.Api.csproj" />
</Solution>