Files
F8WebAI/backend/AgriculturalPlatform.Api/Controllers/InvoicesController.cs
T
fanhongcai c377421a3e 新增农户功能升级:身份证OCR/OSS上传、头像采集、同名村检测、Region热度排序及表单体验优化
- Region 表新增热度列,热门省份优先显示
- 区县下拉按地级市分组、已录区县优先、支持全省搜索
- 组选择内置一组至二十组
- 银行卡号独立行+粗体预览,开户行独立行
- 新增农户类型:农户/个体户/合作社/经营集体/公司
- 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS
- 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展
- 同名村弹窗提醒(防选错乡镇)
- 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作
- 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
2026-08-13 16:07:35 +08:00

350 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}