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