- Region 表新增热度列,热门省份优先显示 - 区县下拉按地级市分组、已录区县优先、支持全省搜索 - 组选择内置一组至二十组 - 银行卡号独立行+粗体预览,开户行独立行 - 新增农户类型:农户/个体户/合作社/经营集体/公司 - 身份证正反面支持高拍仪/摄像头OCR与手机扫码上传,附件存本地/OSS - 新增农户头像采集组件(摄像头/本地上传),预留人脸识别收购扩展 - 同名村弹窗提醒(防选错乡镇) - 编辑窗体禁止遮罩/Esc误关,整体加宽适配高频操作 - 修复二维码局域网手机无法访问(监听0.0.0.0、局域网IP识别、Vite代理兜底)
350 lines
16 KiB
C#
350 lines
16 KiB
C#
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);
|
||
}
|
||
}
|