Platform upgrade: login auth fix, OCR/OSS upload, system menus and dicts, weather dashboard, weighing and invoice modules

This commit is contained in:
2026-08-14 01:12:33 +08:00
parent c377421a3e
commit 8099ebae0a
75 changed files with 5611 additions and 473 deletions
@@ -0,0 +1,22 @@
using AgriculturalPlatform.Api.Dtos;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace AgriculturalPlatform.Api.Controllers;
/// <summary>关于信息</summary>
[ApiController]
[Route("api/about")]
public class AboutController : ControllerBase
{
[HttpGet]
[AllowAnonymous]
public ActionResult<AboutDto> Get() => Ok(new AboutDto(
AppName: "农易富农产品收购交易平台",
Version: "2.0.0",
Copyright: "Copyright © 2026 bbitcn.com 版权所有",
Company: "百博信息技术有限公司",
License: "企业版授权 · 单组织部署许可",
Description: "面向农产品收购企业的数字化管理平台,覆盖农户档案、过磅称重、收购结算、票据打印、通知公告等功能。",
BuiltAt: new DateTime(2026, 8, 13)));
}
@@ -0,0 +1,139 @@
using System.Security.Claims;
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;
/// <summary>通知公告</summary>
[ApiController]
[Route("api/announcements")]
[Authorize]
public class AnnouncementsController(AppDbContext db) : ControllerBase
{
private int UserId => int.Parse(User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "0");
/// <summary>我的公告列表(含已读状态、未读数)</summary>
[HttpGet("my")]
public async Task<ActionResult> My([FromQuery] int page = 1, [FromQuery] int pageSize = 10)
{
var uid = UserId;
var query = db.Announcements.AsNoTracking();
// 范围过滤:空范围=全员可见;含本人组织
var orgId = await db.Users.Where(u => u.Id == uid).Select(u => u.OrgId).FirstOrDefaultAsync();
var queryFiltered = query.Where(a => a.ScopeOrgIds == "" || (orgId != null && a.ScopeOrgIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(orgId.ToString())));
var total = await queryFiltered.CountAsync();
var items = await queryFiltered.OrderByDescending(a => a.IsPinned).ThenByDescending(a => a.PublishedAt)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(a => new
{
a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned,
PublisherName = a.Publisher != null ? a.Publisher.RealName : "",
a.PublishedAt, a.CreatedAt,
IsRead = db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid)
})
.ToListAsync();
var unread = await queryFiltered
.CountAsync(a => !db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid));
return Ok(new { items, total, unread });
}
/// <summary>未读公告数(顶栏徽标)</summary>
[HttpGet("unread-count")]
public async Task<ActionResult> UnreadCount()
{
var uid = UserId;
var orgId = await db.Users.Where(u => u.Id == uid).Select(u => u.OrgId).FirstOrDefaultAsync();
var count = await db.Announcements.CountAsync(a =>
(a.ScopeOrgIds == "" || (orgId != null && a.ScopeOrgIds.Split(',', StringSplitOptions.RemoveEmptyEntries).Contains(orgId.ToString())))
&& !db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid));
return Ok(new { count });
}
/// <summary>公告详情(标记已读)</summary>
[HttpGet("{id:int}")]
public async Task<ActionResult> Get(int id)
{
var uid = UserId;
var a = await db.Announcements.Include(x => x.Publisher).FirstOrDefaultAsync(x => x.Id == id);
if (a is null) return NotFound();
// 标记已读
if (!await db.AnnouncementReads.AnyAsync(r => r.AnnouncementId == id && r.UserId == uid))
{
db.AnnouncementReads.Add(new AnnouncementRead { AnnouncementId = id, UserId = uid, ReadAt = DateTime.Now });
await db.SaveChangesAsync();
}
var dto = new AnnouncementDto(a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned,
a.Publisher?.RealName ?? "", a.PublishedAt, a.CreatedAt, true);
return Ok(dto);
}
/// <summary>全部公告(管理端)</summary>
[HttpGet]
public async Task<ActionResult<PagedResult<AnnouncementDto>>> List(string? keyword, string? type, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var uid = UserId;
var query = db.Announcements.AsNoTracking();
if (!string.IsNullOrWhiteSpace(keyword))
query = query.Where(a => a.Title.Contains(keyword) || a.Content.Contains(keyword));
if (!string.IsNullOrWhiteSpace(type))
query = query.Where(a => a.Type == type);
var total = await query.CountAsync();
var items = await query.OrderByDescending(a => a.PublishedAt)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(a => new AnnouncementDto(
a.Id, a.Title, a.Content, a.Type, a.ScopeOrgIds, a.IsPinned,
a.Publisher != null ? a.Publisher.RealName : "",
a.PublishedAt, a.CreatedAt,
db.AnnouncementReads.Any(r => r.AnnouncementId == a.Id && r.UserId == uid)))
.ToListAsync();
return Ok(new PagedResult<AnnouncementDto>(items, total));
}
/// <summary>发布公告</summary>
[HttpPost]
public async Task<ActionResult> Create(AnnouncementSaveRequest req)
{
if (string.IsNullOrWhiteSpace(req.Title))
return BadRequest(new { message = "标题不能为空" });
var a = new Announcement
{
Title = req.Title, Content = req.Content ?? "", Type = req.Type,
ScopeOrgIds = req.ScopeOrgIds ?? "", IsPinned = req.IsPinned,
PublisherId = UserId, PublishedAt = DateTime.Now, CreatedAt = DateTime.Now
};
db.Announcements.Add(a);
await db.SaveChangesAsync();
return Ok(new { message = "发布成功", id = a.Id });
}
/// <summary>修改公告</summary>
[HttpPut("{id:int}")]
public async Task<ActionResult> Update(int id, AnnouncementSaveRequest req)
{
var a = await db.Announcements.FindAsync(id);
if (a is null) return NotFound();
a.Title = req.Title; a.Content = req.Content ?? ""; a.Type = req.Type;
a.ScopeOrgIds = req.ScopeOrgIds ?? ""; a.IsPinned = req.IsPinned;
await db.SaveChangesAsync();
return Ok(new { message = "保存成功" });
}
/// <summary>删除公告</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var a = await db.Announcements.FindAsync(id);
if (a is null) return NotFound();
db.Announcements.Remove(a);
await db.SaveChangesAsync();
return Ok(new { message = "删除成功" });
}
}
@@ -37,7 +37,7 @@ public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService
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);
var user = await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == cu.Id);
return Ok(BuildInfo(user));
}
@@ -58,5 +58,5 @@ public class AuthController(AppDbContext db, JwtService jwt, CurrentUserService
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);
user.RoleId, user.SysRole?.Name, user.OrgId, user.Org?.Name, user.Org?.Type.ToString(), user.IsActive);
}
@@ -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;
/// <summary>数据字典</summary>
[ApiController]
[Route("api/dicts")]
[Authorize]
public class DictsController(AppDbContext db) : ControllerBase
{
/// <summary>字典类型列表</summary>
[HttpGet]
public async Task<ActionResult<PagedResult<DictDto>>> List(string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var query = db.SysDicts.AsQueryable();
if (!string.IsNullOrWhiteSpace(keyword))
query = query.Where(d => d.Name.Contains(keyword) || d.Code.Contains(keyword));
var total = await query.CountAsync();
var items = await query.OrderBy(d => d.Sort).ThenByDescending(d => d.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(d => new DictDto(d.Id, d.Name, d.Code, d.Remark, d.IsSystem, d.Sort,
d.Items.Count))
.ToListAsync();
return Ok(new PagedResult<DictDto>(items, total));
}
/// <summary>字典全部(含项,供下拉缓存使用)</summary>
[HttpGet("all")]
public async Task<ActionResult> All()
{
var items = await db.SysDicts.AsNoTracking()
.Include(d => d.Items.Where(i => i.Enabled))
.OrderBy(d => d.Sort)
.Select(d => new { d.Code, Items = d.Items.OrderBy(i => i.Sort)
.Select(i => new { i.Label, i.Value, i.Ext, i.IsDefault }) })
.ToListAsync();
return Ok(items);
}
/// <summary>字典详情(含字典项)</summary>
[HttpGet("{id:int}")]
public async Task<ActionResult<DictDetailDto>> Get(int id)
{
var dict = await db.SysDicts.Include(d => d.Items).FirstOrDefaultAsync(d => d.Id == id);
if (dict is null) return NotFound();
var dto = new DictDto(dict.Id, dict.Name, dict.Code, dict.Remark, dict.IsSystem, dict.Sort, dict.Items.Count);
var items = dict.Items.OrderBy(i => i.Sort)
.Select(i => new DictItemDto(i.Id, i.DictId, i.Label, i.Value, i.Ext, i.IsDefault, i.Enabled, i.Sort))
.ToList();
return Ok(new DictDetailDto(dto, items));
}
/// <summary>新增字典类型</summary>
[HttpPost]
public async Task<ActionResult<DictDto>> Create(DictSaveRequest req)
{
if (string.IsNullOrWhiteSpace(req.Code))
return BadRequest(new { message = "字典编码不能为空" });
if (await db.SysDicts.AnyAsync(d => d.Code == req.Code.Trim()))
return BadRequest(new { message = "字典编码已存在" });
var dict = new SysDict
{
Name = req.Name, Code = req.Code.Trim(), Remark = req.Remark, Sort = req.Sort, CreatedAt = DateTime.Now
};
db.SysDicts.Add(dict);
await db.SaveChangesAsync();
return Ok(new DictDto(dict.Id, dict.Name, dict.Code, dict.Remark, dict.IsSystem, dict.Sort, 0));
}
/// <summary>修改字典类型</summary>
[HttpPut("{id:int}")]
public async Task<ActionResult> Update(int id, DictSaveRequest req)
{
var dict = await db.SysDicts.FindAsync(id);
if (dict is null) return NotFound();
if (dict.Code != req.Code.Trim() && await db.SysDicts.AnyAsync(d => d.Code == req.Code.Trim()))
return BadRequest(new { message = "字典编码已存在" });
dict.Name = req.Name; dict.Code = req.Code.Trim(); dict.Remark = req.Remark; dict.Sort = req.Sort;
await db.SaveChangesAsync();
return Ok(new { message = "保存成功" });
}
/// <summary>删除字典类型(系统内置不可删除)</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var dict = await db.SysDicts.FindAsync(id);
if (dict is null) return NotFound();
if (dict.IsSystem) return BadRequest(new { message = "系统内置字典不可删除" });
db.SysDicts.Remove(dict);
await db.SaveChangesAsync();
return Ok(new { message = "删除成功" });
}
// ---------- 字典项 ----------
/// <summary>新增字典项</summary>
[HttpPost("{dictId:int}/items")]
public async Task<ActionResult<DictItemDto>> CreateItem(int dictId, DictItemSaveRequest req)
{
var dict = await db.SysDicts.FindAsync(dictId);
if (dict is null) return NotFound();
if (await db.SysDictItems.AnyAsync(i => i.DictId == dictId && i.Value == req.Value))
return BadRequest(new { message = "字典项值已存在" });
var item = new SysDictItem
{
DictId = dictId, Label = req.Label, Value = req.Value, Ext = req.Ext,
IsDefault = req.IsDefault, Enabled = req.Enabled, Sort = req.Sort
};
db.SysDictItems.Add(item);
await db.SaveChangesAsync();
return Ok(new DictItemDto(item.Id, item.DictId, item.Label, item.Value, item.Ext, item.IsDefault, item.Enabled, item.Sort));
}
/// <summary>修改字典项</summary>
[HttpPut("items/{id:int}")]
public async Task<ActionResult> UpdateItem(int id, DictItemSaveRequest req)
{
var item = await db.SysDictItems.FindAsync(id);
if (item is null) return NotFound();
item.Label = req.Label; item.Value = req.Value; item.Ext = req.Ext;
item.IsDefault = req.IsDefault; item.Enabled = req.Enabled; item.Sort = req.Sort;
await db.SaveChangesAsync();
return Ok(new { message = "保存成功" });
}
/// <summary>删除字典项</summary>
[HttpDelete("items/{id:int}")]
public async Task<IActionResult> DeleteItem(int id)
{
var item = await db.SysDictItems.FindAsync(id);
if (item is null) return NotFound();
db.SysDictItems.Remove(item);
await db.SaveChangesAsync();
return Ok(new { message = "删除成功" });
}
}
@@ -31,6 +31,7 @@ public class FarmersController(AppDbContext db) : ControllerBase
query = query.Where(f => f.Village == village);
if (!string.IsNullOrWhiteSpace(keyword))
query = query.Where(f => f.Name.Contains(keyword)
|| f.PinyinInitials.Contains(keyword.ToUpperInvariant())
|| f.Phone.Contains(keyword)
|| f.IdCard.Contains(keyword)
|| f.Village.Contains(keyword)
@@ -52,9 +53,12 @@ public class FarmersController(AppDbContext db) : ControllerBase
{
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));
query = query.Where(f => f.Name.Contains(keyword)
|| f.PinyinInitials.Contains(keyword.ToUpperInvariant())
|| 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();
.Select(f => new { f.Id, f.Name, f.Phone, f.Province, f.County, f.Township, f.Village, f.GroupName, f.IdCard, f.PinyinInitials }).ToListAsync();
return Ok(items);
}
@@ -88,7 +92,8 @@ public class FarmersController(AppDbContext db) : ControllerBase
var farmer = new Farmer
{
Name = req.Name, IdCard = req.IdCard, Gender = req.Gender, FarmerType = req.FarmerType,
Name = req.Name, PinyinInitials = req.PinyinInitials ?? string.Empty,
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,
@@ -112,7 +117,8 @@ public class FarmersController(AppDbContext db) : ControllerBase
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.Name = req.Name; farmer.PinyinInitials = req.PinyinInitials ?? string.Empty;
farmer.IdCard = req.IdCard; farmer.Gender = req.Gender;
farmer.FarmerType = req.FarmerType;
farmer.Phone = req.Phone;
farmer.Province = req.Province; farmer.County = req.County;
@@ -0,0 +1,35 @@
using AgriculturalPlatform.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace AgriculturalPlatform.Api.Controllers;
/// <summary>
/// 附件读取代理:OSS 模式(私有桶)下前端通过 /api/files/{key} 访问图片,
/// 后端实时从 OSS 拉取并回传,避免将 Bucket 设为公共读而泄露身份证等敏感信息。
/// 本地存储模式不经过此接口(前端直接走静态文件 /uploads)。
/// </summary>
[ApiController]
[Route("api/files")]
[AllowAnonymous]
public class FilesController(IFileStorage storage) : ControllerBase
{
/// <summary>
/// 读取附件。key 形如 uploads/avatar/202608/xxx.png(必须限定 uploads/ 前缀,防止越权读取)。
/// 通过 FileStreamResult 边读边传,减少大图内存占用。
/// </summary>
[HttpGet("{**key}")]
[ResponseCache(Duration = 300)] // 5 分钟缓存,减少重复拉取
public async Task<IActionResult> Get(string key)
{
if (string.IsNullOrWhiteSpace(key) || !key.StartsWith("uploads/"))
return BadRequest(new { message = "无效的文件路径" });
var file = await storage.OpenReadAsync(key);
if (file is null)
return NotFound(new { message = "文件不存在或已被删除" });
var (stream, contentType) = file.Value;
return File(stream, contentType, enableRangeProcessing: true);
}
}
@@ -0,0 +1,112 @@
using System.Security.Claims;
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;
/// <summary>菜单/权限管理</summary>
[ApiController]
[Route("api/menus")]
[Authorize]
public class MenusController(AppDbContext db) : ControllerBase
{
/// <summary>菜单树(全部)</summary>
[HttpGet]
public async Task<ActionResult> Tree()
{
var menus = await db.SysMenus.AsNoTracking().OrderBy(m => m.Sort).ThenBy(m => m.Id).ToListAsync();
return Ok(BuildTree(menus, null));
}
/// <summary>当前登录用户的菜单与权限</summary>
[HttpGet("my")]
public async Task<ActionResult> My()
{
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userId is null) return Unauthorized();
// 用户角色 -> 菜单
var user = await db.Users.Include(u => u.SysRole).FirstOrDefaultAsync(u => u.Id == int.Parse(userId));
if (user is null) return Unauthorized();
int[] menuIds;
if (user.SysRole?.Code == "super_admin" || user.Role == UserRole.SuperAdmin)
{
menuIds = await db.SysMenus.Select(m => m.Id).ToArrayAsync();
}
else if (user.RoleId is int rid)
{
menuIds = await db.SysRoleMenus.Where(rm => rm.RoleId == rid).Select(rm => rm.MenuId).ToArrayAsync();
}
else
{
menuIds = Array.Empty<int>();
}
var menus = await db.SysMenus.AsNoTracking()
.Where(m => menuIds.Contains(m.Id))
.OrderBy(m => m.Sort).ThenBy(m => m.Id)
.ToListAsync();
var tree = BuildTree(menus, null);
var permissions = menus.Where(m => !string.IsNullOrWhiteSpace(m.Permission)).Select(m => m.Permission).Distinct().ToArray();
return Ok(new { menus = tree, permissions });
}
private static List<MenuDto> BuildTree(List<SysMenu> all, int? parentId)
{
return all.Where(m => m.ParentId == parentId)
.Select(m => new MenuDto(
m.Id, m.ParentId, m.Name, m.Path, m.Component, m.Icon, m.Type,
m.Permission, m.Visible, m.Sort, BuildTree(all, m.Id)))
.ToList();
}
/// <summary>新增菜单</summary>
[HttpPost]
public async Task<ActionResult> Create(MenuSaveRequest req)
{
var menu = new SysMenu
{
ParentId = req.ParentId, Name = req.Name, Path = req.Path, Component = req.Component,
Icon = req.Icon, Type = req.Type, Permission = req.Permission, Visible = req.Visible,
Sort = req.Sort, CreatedAt = DateTime.Now
};
db.SysMenus.Add(menu);
await db.SaveChangesAsync();
return Ok(new { message = "保存成功", id = menu.Id });
}
/// <summary>修改菜单</summary>
[HttpPut("{id:int}")]
public async Task<ActionResult> Update(int id, MenuSaveRequest req)
{
var menu = await db.SysMenus.FindAsync(id);
if (menu is null) return NotFound();
if (req.ParentId == id) return BadRequest(new { message = "父级不能选择自身" });
menu.ParentId = req.ParentId; menu.Name = req.Name; menu.Path = req.Path;
menu.Component = req.Component; menu.Icon = req.Icon; menu.Type = req.Type;
menu.Permission = req.Permission; menu.Visible = req.Visible; menu.Sort = req.Sort;
await db.SaveChangesAsync();
return Ok(new { message = "保存成功" });
}
/// <summary>删除菜单(有子菜单或已被角色引用时禁止删除)</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
if (await db.SysMenus.AnyAsync(m => m.ParentId == id))
return BadRequest(new { message = "请先删除子菜单" });
if (await db.SysRoleMenus.AnyAsync(rm => rm.MenuId == id))
return BadRequest(new { message = "该菜单已被角色引用,请先解除关联" });
var menu = await db.SysMenus.FindAsync(id);
if (menu is null) return NotFound();
db.SysMenus.Remove(menu);
await db.SaveChangesAsync();
return Ok(new { message = "删除成功" });
}
}
@@ -27,6 +27,7 @@ public class PurchasesController(
var query = db.PurchaseOrders
.Include(p => p.Farmer).Include(p => p.Product)
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
.Include(p => p.Weighs)
.AsQueryable();
if (visible is not null)
@@ -73,11 +74,42 @@ public class PurchasesController(
var order = await db.PurchaseOrders
.Include(p => p.Farmer).Include(p => p.Product)
.Include(p => p.PurchaserOrg).Include(p => p.Operator)
.Include(p => p.Weighs)
.FirstOrDefaultAsync(p => p.Id == id);
if (order is null) return NotFound();
return Ok(order.ToDto());
}
/// <summary>收购单统计(单数、重量合计、均价、金额合计)</summary>
[HttpGet("summary")]
public async Task<ActionResult<PurchaseSummaryDto>> Summary(
int? farmerId, int? productId, int? orgId, DateTime? from, DateTime? to)
{
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));
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));
var orderCount = await query.CountAsync();
var totalNet = await query.SumAsync(p => (decimal?)p.NetWeight) ?? 0;
var totalAmount = await query.SumAsync(p => (decimal?)p.Amount) ?? 0;
var farmerCount = await query.Select(p => p.FarmerId).Distinct().CountAsync();
var todayStart = DateTime.Today;
var todayOrderCount = await db.PurchaseOrders
.CountAsync(p => p.Status == PurchaseStatus.Completed && p.CreatedAt >= todayStart);
var avgPrice = totalNet > 0 ? Math.Round(totalAmount / totalNet, 2) : 0;
return Ok(new PurchaseSummaryDto(orderCount, Math.Round(totalNet, 2), avgPrice,
Math.Round(totalAmount, 2), farmerCount, todayOrderCount));
}
/// <summary>新建收购单(第一次过磅:记录毛重)</summary>
[HttpPost]
public async Task<ActionResult<PurchaseDto>> Create(PurchaseCreateRequest req)
@@ -162,6 +194,138 @@ public class PurchasesController(
.FirstAsync(p => p.Id == order.Id)).ToDto());
}
/// <summary>多磅收购单:一次性保存(一单多磅、多等级),完成后直接打印</summary>
[HttpPost("multi")]
public async Task<ActionResult<PurchaseDto>> CreateMulti(PurchaseMultiCreateRequest req)
{
if (req.Weighs is null || req.Weighs.Count == 0)
return BadRequest(new { message = "请至少录入一磅数据" });
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,
Unit = string.IsNullOrWhiteSpace(req.Unit) ? "公斤" : req.Unit,
WeighCount = req.Weighs.Count,
Status = PurchaseStatus.Completed,
OperatorId = cu.Id,
Notes = req.Notes ?? "",
WeighInAt = DateTime.Now,
WeighOutAt = DateTime.Now,
CreatedAt = DateTime.Now,
UpdatedAt = DateTime.Now
};
var sortNo = 1;
foreach (var w in req.Weighs)
{
var net = w.NetWeight ?? Math.Round(w.GrossWeight - w.TareWeight, 2);
var amount = Math.Round(net * w.UnitPrice, 2);
order.Weighs.Add(new PurchaseWeigh
{
SortNo = sortNo++,
Grade = w.Grade,
PriceType = w.PriceType,
UnitPrice = w.UnitPrice,
GrossWeight = w.GrossWeight,
TareWeight = w.TareWeight,
NetWeight = net,
BoxCount = w.BoxCount,
Amount = amount,
CreatedAt = DateTime.Now
});
order.GrossWeight += w.GrossWeight;
order.TareWeight += w.TareWeight;
order.NetWeight += net;
order.Amount += amount;
order.BoxCount += w.BoxCount;
}
order.NetWeight = Math.Round(order.NetWeight, 2);
order.Amount = Math.Round(order.Amount, 2);
// 主字段取第一磅
var first = order.Weighs.First();
order.Grade = first.Grade;
order.UnitPrice = first.UnitPrice;
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)
.Include(p => p.Weighs)
.FirstAsync(p => p.Id == order.Id)).ToDto());
}
/// <summary>补打票据(记录补打日志到 Notes</summary>
[HttpPost("{id:int}/reprint")]
public async Task<ActionResult> Reprint(int id)
{
var order = await db.PurchaseOrders.FindAsync(id);
if (order is null) return NotFound();
var cu = currentUser.Get()!;
var log = $"补打票据 {DateTime.Now:yyyy-MM-dd HH:mm} by {cu.RealName}";
order.Notes = string.IsNullOrWhiteSpace(order.Notes) ? log : order.Notes + "" + log;
order.UpdatedAt = DateTime.Now;
await db.SaveChangesAsync();
return Ok(new { message = "已记录补打" });
}
/// <summary>票据过户(生成过户单号并记录)</summary>
[HttpPost("{id:int}/transfer")]
public async Task<ActionResult> Transfer(int id, PurchaseTransferRequest req)
{
var order = await db.PurchaseOrders.Include(p => p.Farmer).FirstOrDefaultAsync(p => p.Id == id);
if (order is null) return NotFound();
if (string.IsNullOrWhiteSpace(req.ToFarmer))
return BadRequest(new { message = "请填写过户到农户" });
if (order.Status != PurchaseStatus.Completed)
return BadRequest(new { message = "仅已完成单据可过户" });
var cu = currentUser.Get()!;
var transfer = new PurchaseTransfer
{
PurchaseOrderId = order.Id,
TransferNo = await numberGen.NextAsync("GH"),
FromFarmer = order.Farmer?.Name ?? "",
ToFarmer = req.ToFarmer,
ToIdCard = req.ToIdCard ?? "",
Reason = req.Reason ?? "",
OperatorId = cu.Id,
CreatedAt = DateTime.Now
};
db.PurchaseTransfers.Add(transfer);
order.UpdatedAt = DateTime.Now;
await db.SaveChangesAsync();
return Ok(new { message = "过户成功", transferNo = transfer.TransferNo });
}
/// <summary>收购单过户记录</summary>
[HttpGet("{id:int}/transfers")]
public async Task<ActionResult> Transfers(int id)
{
var items = await db.PurchaseTransfers.AsNoTracking()
.Include(t => t.Operator)
.Where(t => t.PurchaseOrderId == id)
.OrderByDescending(t => t.CreatedAt)
.Select(t => new PurchaseTransferDto(
t.Id, t.TransferNo, t.FromFarmer, t.ToFarmer, t.ToIdCard, t.Reason,
t.Operator != null ? t.Operator.RealName : "", t.CreatedAt))
.ToListAsync();
return Ok(items);
}
/// <summary>作废收购单</summary>
[HttpPut("{id:int}/cancel")]
public async Task<IActionResult> Cancel(int id)
@@ -0,0 +1,106 @@
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;
/// <summary>角色管理(RBAC</summary>
[ApiController]
[Route("api/roles")]
[Authorize]
public class RolesController(AppDbContext db) : ControllerBase
{
/// <summary>角色列表</summary>
[HttpGet]
public async Task<ActionResult<PagedResult<RoleDto>>> List(string? keyword, [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
var query = db.SysRoles.AsQueryable();
if (!string.IsNullOrWhiteSpace(keyword))
query = query.Where(r => r.Name.Contains(keyword) || r.Code.Contains(keyword));
var total = await query.CountAsync();
var items = await query.OrderBy(r => r.Id)
.Skip((page - 1) * pageSize).Take(pageSize)
.Select(r => new RoleDto(
r.Id, r.Name, r.Code, r.Description, r.IsSystem,
db.SysRoleMenus.Where(rm => rm.RoleId == r.Id).Select(rm => rm.MenuId).ToArray()))
.ToListAsync();
return Ok(new PagedResult<RoleDto>(items, total));
}
/// <summary>全部角色(下拉选择用)</summary>
[HttpGet("all")]
public async Task<ActionResult> All()
{
var items = await db.SysRoles.AsNoTracking().OrderBy(r => r.Id)
.Select(r => new { r.Id, r.Name, r.Code }).ToListAsync();
return Ok(items);
}
/// <summary>新增角色</summary>
[HttpPost]
public async Task<ActionResult> Create(RoleSaveRequest req)
{
if (string.IsNullOrWhiteSpace(req.Code))
return BadRequest(new { message = "角色编码不能为空" });
if (await db.SysRoles.AnyAsync(r => r.Code == req.Code.Trim()))
return BadRequest(new { message = "角色编码已存在" });
var role = new SysRole
{
Name = req.Name, Code = req.Code.Trim(), Description = req.Description, CreatedAt = DateTime.Now
};
db.SysRoles.Add(role);
await db.SaveChangesAsync();
if (req.MenuIds.Length > 0)
{
db.SysRoleMenus.AddRange(req.MenuIds.Distinct().Select(mid => new SysRoleMenu { RoleId = role.Id, MenuId = mid }));
await db.SaveChangesAsync();
}
return Ok(new { message = "保存成功", id = role.Id });
}
/// <summary>修改角色</summary>
[HttpPut("{id:int}")]
public async Task<ActionResult> Update(int id, RoleSaveRequest req)
{
var role = await db.SysRoles.FindAsync(id);
if (role is null) return NotFound();
role.Name = req.Name; role.Description = req.Description;
// 系统内置角色编码不可改
if (!role.IsSystem) role.Code = req.Code.Trim();
var old = await db.SysRoleMenus.Where(rm => rm.RoleId == id).Select(rm => rm.MenuId).ToListAsync();
var newMenuIds = req.MenuIds.Distinct().ToArray();
var toRemove = old.Where(m => !newMenuIds.Contains(m)).ToList();
var toAdd = newMenuIds.Where(m => !old.Contains(m)).ToList();
if (toRemove.Count > 0)
db.SysRoleMenus.RemoveRange(toRemove.Select(m => new SysRoleMenu { RoleId = id, MenuId = m }));
if (toAdd.Count > 0)
db.SysRoleMenus.AddRange(toAdd.Select(m => new SysRoleMenu { RoleId = id, MenuId = m }));
await db.SaveChangesAsync();
return Ok(new { message = "保存成功" });
}
/// <summary>删除角色(系统内置或有用户引用时禁止删除)</summary>
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id)
{
var role = await db.SysRoles.FindAsync(id);
if (role is null) return NotFound();
if (role.IsSystem) return BadRequest(new { message = "系统内置角色不可删除" });
if (await db.Users.AnyAsync(u => u.RoleId == id))
return BadRequest(new { message = "该角色已被用户使用,请先解除关联" });
db.SysRoleMenus.RemoveRange(db.SysRoleMenus.Where(rm => rm.RoleId == id));
db.SysRoles.Remove(role);
await db.SaveChangesAsync();
return Ok(new { message = "删除成功" });
}
}
@@ -45,7 +45,10 @@ public class UploadsController(
return BadRequest(new { message = ex.Message });
}
var result = await ocr.RecognizeAsync(ms, side, file.FileName);
// 反面仅存档至 OSS,不调用 OCR(OCR 只识别正面)
IdCardOcrResult? result = null;
if (side == "front")
result = await ocr.RecognizeAsync(ms, side, file.FileName, saved.Key);
return Ok(new { url = saved.Url, key = saved.Key, side, ocr = result });
}
@@ -120,7 +123,7 @@ public class UploadsController(
try
{
session.Front = await storage.SaveAsync("idcard", ms, front.FileName, front.ContentType);
session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName);
session.FrontOcr = await ocr.RecognizeAsync(ms, "front", front.FileName, session.Front.Key);
}
catch (InvalidOperationException ex)
{
@@ -135,8 +138,9 @@ public class UploadsController(
ms.Position = 0;
try
{
// 反面仅存档,不调用 OCR
session.Back = await storage.SaveAsync("idcard", ms, back.FileName, back.ContentType);
session.BackOcr = await ocr.RecognizeAsync(ms, "back", back.FileName);
session.BackOcr = null;
}
catch (InvalidOperationException ex)
{
@@ -159,7 +163,7 @@ public class UploadsController(
if (!session.Done)
return Ok(new { done = false });
var ocr = MergeOcr(session.FrontOcr, session.BackOcr);
// 反面不再识别,仅返回正面 OCR 结果(可能为 null)
return Ok(new
{
done = true,
@@ -167,20 +171,10 @@ public class UploadsController(
frontKey = session.Front?.Key,
backUrl = session.Back?.Url,
backKey = session.Back?.Key,
ocr
ocr = session.FrontOcr
});
}
/// <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 等虚拟网卡),
@@ -20,7 +20,7 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse
[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();
var query = db.Users.Include(u => u.Org).Include(u => u.SysRole).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))
@@ -54,12 +54,13 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse
RealName = req.RealName,
Phone = req.Phone,
Role = role,
RoleId = req.RoleId,
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());
return Ok((await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == user.Id)).ToDto());
}
/// <summary>修改用户</summary>
@@ -75,12 +76,13 @@ public class UsersController(AppDbContext db, DataScopeService scope, CurrentUse
user.RealName = req.RealName;
user.Phone = req.Phone;
if (Enum.TryParse<UserRole>(req.Role, out var role)) user.Role = role;
user.RoleId = req.RoleId;
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());
return Ok((await db.Users.Include(u => u.Org).Include(u => u.SysRole).FirstAsync(u => u.Id == user.Id)).ToDto());
}
/// <summary>重置密码</summary>