新增农户功能升级:身份证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,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 = "删除成功" });
}
}