admin/src/Oms.Application/Services/Warehouse/WarehouseService.Inbound.cs
zzh 6021562fea
All checks were successful
Build and Deploy / deploy (push) Successful in 1m5s
feat: 入库预报支持预报/待入库编辑,唛头选填
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 18:54:33 +08:00

553 lines
22 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 System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Oms.Application.Labeling;
using Oms.Application.Shared;
using Oms.Application.Storage;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.WarehouseAggregate.Consts;
using Oms.Domain.WarehouseAggregate.Entities;
namespace Oms.Application.Services.Warehouse;
public partial class WarehouseService
{
/// <summary>
/// 查询入库预报列表。
/// </summary>
/// <param name="input">入参:入库预报分页查询条件。</param>
/// <returns>入库预报分页结果。</returns>
[HttpPost("/warehouse/inbound/list")]
public async Task<OmsPageResult<WarehouseInboundOutput>> GetInboundListAsync(WarehouseInboundListInput input)
{
var customerId = await ResolveListCustomerIdAsync(input.CustomerId);
var (hasCustomerFilter, customerIdValue) = CustomerFilter(customerId);
var keyword = input.Keyword?.Trim();
var hasKeyword = !string.IsNullOrWhiteSpace(keyword);
// 前端 Tab「全部」会传 all / 空;需忽略,不能按字面 ReceiptStatus == "all" 过滤
var receiptStatus = NormalizeInboundReceiptStatusFilter(input.ReceiptStatus);
var hasReceiptStatusFilter = !string.IsNullOrWhiteSpace(receiptStatus);
var hasWarehouseFilter = input.WarehouseId.HasValue;
var warehouseIdValue = input.WarehouseId.GetValueOrDefault();
Expression<Func<CInboundNotice, bool>> predicate = hasReceiptStatusFilter
? x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& x.ReceiptStatus == receiptStatus
&& (!hasKeyword || x.NoticeNo.Contains(keyword!) || (x.MarkReference != null && x.MarkReference.Contains(keyword!)))
: x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& (!hasKeyword || x.NoticeNo.Contains(keyword!) || (x.MarkReference != null && x.MarkReference.Contains(keyword!)));
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
// 避免 Fake GetPagedListAsync(sorting) 丢失 Where:total 正确但 items 返回全量
var (entities, total) = await RepositoryPageHelper.GetPageByUpdateTimeDescAsync(
InboundNoticeRepository,
predicate,
pageIndex,
pageSize);
var noticeIds = entities.Select(x => x.Id).ToArray();
var boxes = noticeIds.Length == 0
? []
: await InboundBoxRepository.GetListAsync(x => noticeIds.Contains(x.InboundNoticeId));
var boxIds = boxes.Select(x => x.Id).ToArray();
var lines = boxIds.Length == 0
? []
: await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId));
var customerIds = entities.Select(x => x.CustomerId).Distinct().ToArray();
var customers = customerIds.Length == 0
? []
: await CustomerRepository.GetListAsync(x => customerIds.Contains(x.Id));
var customerMap = customers.ToDictionary(x => x.Id);
return new OmsPageResult<WarehouseInboundOutput>
{
Items = entities.Select(x =>
{
var output = MapInboundOutput(x, boxes, lines);
if (customerMap.TryGetValue(x.CustomerId, out var customer))
{
output.CustomerName = customer.CustomerName;
output.CustomerCode = customer.CustomerCode;
}
return output;
}).ToList(),
Total = total
};
}
/// <summary>
/// 规范化入库状态筛选:空 / all 表示不按状态过滤;已知编码统一为规范值。
/// </summary>
private static string? NormalizeInboundReceiptStatusFilter(string? status)
{
var value = status?.Trim();
if (string.IsNullOrEmpty(value)
|| value.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (value.Equals(WarehouseReceiptStatusCodes.Forecasted, StringComparison.OrdinalIgnoreCase))
{
return WarehouseReceiptStatusCodes.Forecasted;
}
if (value.Equals(WarehouseReceiptStatusCodes.Received, StringComparison.OrdinalIgnoreCase))
{
return WarehouseReceiptStatusCodes.Received;
}
if (value.Equals(WarehouseReceiptStatusCodes.Completed, StringComparison.OrdinalIgnoreCase))
{
return WarehouseReceiptStatusCodes.Completed;
}
return value;
}
/// <summary>
/// 查询入库预报详情。
/// </summary>
/// <param name="id">入库预报 ID。</param>
/// <param name="customerId">客户 ID(可选)。</param>
/// <returns>入库预报详情(含箱与明细)。</returns>
[HttpGet("/warehouse/inbound/detail")]
public async Task<WarehouseInboundDetailOutput> GetInboundDetailAsync(
[FromQuery] int id,
[FromQuery] int? customerId = null)
{
var resolvedCustomerId = await ResolveListCustomerIdAsync(customerId);
var notice = await GetInboundNoticeOrThrowAsync(resolvedCustomerId, id);
var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == notice.Id);
boxes = boxes.OrderBy(x => x.BoxSeqNo).ThenBy(x => x.Id).ToList();
var boxIds = boxes.Select(x => x.Id).ToArray();
var lines = boxIds.Length == 0
? []
: await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId));
var output = MapInboundOutput(notice, boxes, lines);
return new WarehouseInboundDetailOutput
{
Id = output.Id,
CustomerId = output.CustomerId,
WarehouseId = output.WarehouseId,
NoticeNo = output.NoticeNo,
MarkReference = output.MarkReference,
InboundType = output.InboundType,
ReceiptStatus = output.ReceiptStatus,
ExpectedArrivalTime = output.ExpectedArrivalTime,
ActualArrivalTime = output.ActualArrivalTime,
Remark = output.Remark,
BoxCount = output.BoxCount,
PlannedQty = output.PlannedQty,
CreateTime = output.CreateTime,
Boxes = boxes.Select(box => MapInboundBox(box, lines.Where(l => l.InboundBoxId == box.Id))).ToList()
};
}
/// <summary>
/// 保存入库预报(新增或编辑)。编辑仅允许「预报」「待入库」状态;唛头(MarkReference)选填。
/// </summary>
/// <param name="input">入参:入库预报保存数据。</param>
/// <returns>保存后的入库预报详情。</returns>
[HttpPost("/warehouse/inbound/save")]
public async Task<WarehouseInboundDetailOutput> SaveInboundAsync(WarehouseInboundSaveInput input)
{
var customerId = await ResolveMutationCustomerIdAsync(input.CustomerId);
if (input.WarehouseId <= 0)
{
throw new BusinessException("仓库ID不能为空");
}
if (input.Boxes.Count == 0)
{
throw new BusinessException("请至少添加一个箱子");
}
CInboundNotice notice;
if (input.Id > 0)
{
notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id);
if (notice.ReceiptStatus != WarehouseReceiptStatusCodes.Forecasted
&& notice.ReceiptStatus != WarehouseReceiptStatusCodes.Received)
{
throw new BusinessException("仅预报或待入库状态可编辑");
}
}
else
{
notice = new CInboundNotice
{
CustomerId = customerId,
NoticeNo = WarehouseCustomerHelper.GenerateInboundNoticeNo(),
ReceiptStatus = WarehouseReceiptStatusCodes.Forecasted
};
}
notice.WarehouseId = input.WarehouseId;
notice.MarkReference = string.IsNullOrWhiteSpace(input.MarkReference) ? null : input.MarkReference.Trim();
notice.InboundType = string.IsNullOrWhiteSpace(input.InboundType)
? WarehouseInboundTypeCodes.Normal
: input.InboundType.Trim();
notice.ExpectedArrivalTime = input.ExpectedArrivalTime;
notice.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim();
if (input.Id > 0)
{
await InboundNoticeRepository.UpdateAsync(notice);
await ClearInboundBoxesAsync(notice.Id);
}
else
{
await InboundNoticeRepository.InsertAsync(notice);
}
foreach (var boxInput in input.Boxes)
{
var box = new CInboundBox
{
InboundNoticeId = notice.Id,
BoxSeqNo = string.IsNullOrWhiteSpace(boxInput.BoxSeqNo) ? "1" : boxInput.BoxSeqNo.Trim(),
MarkBoxNo = string.IsNullOrWhiteSpace(boxInput.MarkBoxNo) ? null : boxInput.MarkBoxNo.Trim(),
WeightG = boxInput.WeightG,
LengthCm = boxInput.LengthCm,
WidthCm = boxInput.WidthCm,
HeightCm = boxInput.HeightCm
};
await InboundBoxRepository.InsertAsync(box);
foreach (var lineInput in boxInput.Lines)
{
if (lineInput.SkuId <= 0 || lineInput.PlannedQty <= 0)
{
throw new BusinessException("箱内 SKU 行数据不完整");
}
var sku = await GetSkuOrThrowAsync(customerId, lineInput.SkuId);
var line = new CInboundBoxLine
{
InboundBoxId = box.Id,
SkuId = sku.Id,
SkuCode = string.IsNullOrWhiteSpace(lineInput.SkuCode) ? sku.SkuCode : lineInput.SkuCode.Trim(),
PlannedQty = lineInput.PlannedQty
};
await InboundBoxLineRepository.InsertAsync(line);
}
}
return await GetInboundDetailAsync(notice.Id, customerId);
}
/// <summary>
/// 删除入库预报。
/// </summary>
/// <param name="input">入参:入库预报 ID。</param>
[HttpPost("/warehouse/inbound/delete")]
public async Task DeleteInboundAsync(IdInput input)
{
var customerId = await ResolveListCustomerIdAsync(null);
var notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id);
if (notice.ReceiptStatus != WarehouseReceiptStatusCodes.Forecasted)
{
throw new BusinessException("仅预报状态可删除");
}
await ClearInboundBoxesAsync(notice.Id);
await InboundNoticeRepository.DeleteAsync(notice);
}
/// <summary>
/// 确认入库收货并上架。
/// </summary>
/// <param name="input">入参:收货箱与实收数量。</param>
[HttpPost("/warehouse/inbound/confirm-receive")]
public async Task ConfirmInboundReceiveAsync(WarehouseInboundConfirmReceiveInput input)
{
var customerId = await ResolveMutationCustomerIdAsync(input.CustomerId);
var notice = await GetInboundNoticeOrThrowAsync(customerId, input.InboundNoticeId);
if (notice.ReceiptStatus == WarehouseReceiptStatusCodes.Completed)
{
throw new BusinessException("入库单已完成收货");
}
if (input.Boxes.Count == 0)
{
throw new BusinessException("请提交收货箱数据");
}
var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == notice.Id);
var boxMap = boxes.ToDictionary(x => x.Id);
var boxIds = boxes.Select(x => x.Id).ToArray();
var lines = boxIds.Length == 0
? []
: await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId));
var lineMap = lines.ToDictionary(x => x.Id);
foreach (var boxInput in input.Boxes)
{
if (!boxMap.TryGetValue(boxInput.BoxId, out var box))
{
throw new BusinessException($"箱 {boxInput.BoxId} 不存在");
}
foreach (var lineInput in boxInput.Lines)
{
if (!lineMap.TryGetValue(lineInput.LineId, out var line) || line.InboundBoxId != box.Id)
{
throw new BusinessException($"箱内行 {lineInput.LineId} 不存在");
}
if (lineInput.ReceivedQty < 0)
{
throw new BusinessException("实收数量不能为负");
}
if (lineInput.ReceivedQty > 0 && lineInput.BinLocationId <= 0)
{
throw new BusinessException("上架库位不能为空");
}
var previousQty = line.ReceivedQty ?? 0;
var deltaQty = lineInput.ReceivedQty - previousQty;
line.ReceivedQty = lineInput.ReceivedQty;
line.BinLocationId = lineInput.ReceivedQty > 0 ? lineInput.BinLocationId : null;
await InboundBoxLineRepository.UpdateAsync(line);
if (deltaQty > 0)
{
await UpsertInventoryQtyAsync(
customerId,
notice.WarehouseId,
line.SkuId,
lineInput.BinLocationId,
deltaQty);
}
}
box.IsReceived = boxInput.Lines.All(x =>
{
if (!lineMap.TryGetValue(x.LineId, out var line))
{
return false;
}
return x.ReceivedQty >= line.PlannedQty;
});
await InboundBoxRepository.UpdateAsync(box);
}
notice.ReceiptStatus = WarehouseReceiptStatusCodes.Completed;
notice.ActualArrivalTime = DateTime.Now;
await InboundNoticeRepository.UpdateAsync(notice);
}
/// <summary>
/// 打印入库箱唛标签:按箱渲染后合并为一个多页 PDF,返回可打印 URL。
/// </summary>
/// <param name="input">入参:入库预报 ID(管理端可带 customerId)。</param>
/// <returns>printUrl / noticeNo / pageCount。</returns>
[HttpPost("/warehouse/inbound/print-labels")]
public async Task<WarehouseInboundPrintLabelsOutput> PrintInboundLabelsAsync(
WarehouseInboundPrintLabelsInput input)
{
if (input.Id <= 0)
{
throw new BusinessException("入库预报 ID 无效");
}
var customerId = await ResolveListCustomerIdAsync(input.CustomerId);
var notice = await GetInboundNoticeOrThrowAsync(customerId, input.Id);
var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == notice.Id);
boxes = boxes.OrderBy(x => x.BoxSeqNo).ThenBy(x => x.Id).ToList();
if (boxes.Count == 0)
{
throw new BusinessException("该预报没有箱子,无法打印箱唛");
}
var boxType = LabelTemplateTypeCodes.Box;
var templates = await LabelTemplateRepository.GetListAsync(x => x.TemplateType == boxType);
var template = templates
.Where(x => !string.IsNullOrWhiteSpace(x.TemplateJson))
.OrderByDescending(x => x.UpdateTime)
.ThenByDescending(x => x.Id)
.FirstOrDefault();
if (template == null)
{
throw new BusinessException("未配置箱唛标签模板(类型 box),请先在标签设计中创建");
}
var boxIds = boxes.Select(x => x.Id).ToArray();
var lines = await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId));
var skuIds = lines.Select(x => x.SkuId).Distinct().ToArray();
var skus = skuIds.Length == 0
? []
: await SkuRepository.GetListAsync(x => skuIds.Contains(x.Id));
var skuMap = skus.ToDictionary(x => x.Id);
var warehouse = await WarehouseAddressRepository.FirstOrDefaultAsync(x => x.Id == notice.WarehouseId);
var customer = await CustomerRepository.FirstOrDefaultAsync(x => x.Id == notice.CustomerId);
var operatorName = CurrentUser?.UserName;
var pageCount = boxes.Count;
var printPages = new List<IReadOnlyDictionary<string, object?>>(pageCount);
for (var i = 0; i < boxes.Count; i++)
{
var box = boxes[i];
var boxLines = lines.Where(x => x.InboundBoxId == box.Id).ToList();
printPages.Add(InboundBoxLabelPrintDataBuilder.Build(
notice,
box,
boxLines,
skuMap,
warehouseName: warehouse?.WarehouseName,
customerName: customer?.CustomerName,
customerCode: customer?.CustomerCode,
operatorName: operatorName,
pageIndex: i + 1,
pageCount: pageCount));
}
var pdfBytes = await LabelPdfRenderer.RenderManyAsync(template.TemplateJson!, printPages);
var printUrl = await SaveInboundLabelPdfAsync(notice.NoticeNo, pdfBytes);
return new WarehouseInboundPrintLabelsOutput
{
PrintUrl = printUrl,
NoticeNo = notice.NoticeNo,
PageCount = pageCount
};
}
/// <summary>
/// 查询入库预报状态汇总。
/// </summary>
/// <param name="customerId">客户 ID(可选)。</param>
/// <param name="warehouseId">仓库 ID(可选)。</param>
/// <returns>各状态数量汇总。</returns>
[HttpGet("/warehouse/inbound/summary")]
public async Task<WarehouseInboundSummaryOutput> GetInboundSummaryAsync([FromQuery] int? customerId = null, [FromQuery] int? warehouseId = null)
{
var resolvedCustomerId = await ResolveListCustomerIdAsync(customerId);
var (hasCustomerFilter, customerIdValue) = CustomerFilter(resolvedCustomerId);
var hasWarehouseFilter = warehouseId.HasValue;
var warehouseIdValue = warehouseId.GetValueOrDefault();
var notices = await InboundNoticeRepository.GetListAsync(x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue));
return new WarehouseInboundSummaryOutput
{
Forecasted = notices.Count(x => x.ReceiptStatus == WarehouseReceiptStatusCodes.Forecasted),
Received = notices.Count(x => x.ReceiptStatus == WarehouseReceiptStatusCodes.Received),
Completed = notices.Count(x => x.ReceiptStatus == WarehouseReceiptStatusCodes.Completed)
};
}
private async Task<CInboundNotice> GetInboundNoticeOrThrowAsync(int? customerId, int id)
{
var entity = customerId.HasValue
? await InboundNoticeRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId.Value)
: await InboundNoticeRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("入库预报不存在");
}
return entity;
}
private async Task<string> SaveInboundLabelPdfAsync(string noticeNo, byte[] pdfBytes)
{
var safeNo = string.IsNullOrWhiteSpace(noticeNo) ? "inbound" : noticeNo.Trim();
foreach (var c in Path.GetInvalidFileNameChars())
{
safeNo = safeNo.Replace(c, '_');
}
var fileName = $"{safeNo}-{DateTime.Now:yyyyMMddHHmmss}.pdf";
var relativeKey = $"uploads/prints/inbound-labels/{fileName}";
await using var stream = new MemoryStream(pdfBytes);
var key = await FileStorage.SaveObjectAsync(stream, relativeKey, "application/pdf");
return FileStorage.GetPublicUrl(key);
}
private async Task ClearInboundBoxesAsync(int noticeId)
{
var boxes = await InboundBoxRepository.GetListAsync(x => x.InboundNoticeId == noticeId);
if (boxes.Count == 0)
{
return;
}
var boxIds = boxes.Select(x => x.Id).ToArray();
var lines = await InboundBoxLineRepository.GetListAsync(x => boxIds.Contains(x.InboundBoxId));
foreach (var line in lines)
{
await InboundBoxLineRepository.DeleteAsync(line);
}
foreach (var box in boxes)
{
await InboundBoxRepository.DeleteAsync(box);
}
}
private static WarehouseInboundOutput MapInboundOutput(
CInboundNotice notice,
IReadOnlyCollection<CInboundBox> boxes,
IReadOnlyCollection<CInboundBoxLine> lines)
{
var noticeBoxes = boxes.Where(x => x.InboundNoticeId == notice.Id).ToList();
var noticeBoxIds = noticeBoxes.Select(x => x.Id).ToHashSet();
var noticeLines = lines.Where(x => noticeBoxIds.Contains(x.InboundBoxId)).ToList();
return new WarehouseInboundOutput
{
Id = notice.Id,
CustomerId = notice.CustomerId,
WarehouseId = notice.WarehouseId,
NoticeNo = notice.NoticeNo,
MarkReference = notice.MarkReference,
InboundType = notice.InboundType,
ReceiptStatus = notice.ReceiptStatus,
ExpectedArrivalTime = notice.ExpectedArrivalTime,
ActualArrivalTime = notice.ActualArrivalTime,
Remark = notice.Remark,
BoxCount = noticeBoxes.Count,
PlannedQty = noticeLines.Sum(x => x.PlannedQty),
CreateTime = notice.CreateTime
};
}
private static WarehouseInboundBoxOutput MapInboundBox(CInboundBox box, IEnumerable<CInboundBoxLine> lines) =>
new()
{
Id = box.Id,
BoxSeqNo = box.BoxSeqNo,
MarkBoxNo = box.MarkBoxNo,
WeightG = box.WeightG,
LengthCm = box.LengthCm,
WidthCm = box.WidthCm,
HeightCm = box.HeightCm,
IsReceived = box.IsReceived,
Lines = lines.Select(line => new WarehouseInboundLineOutput
{
Id = line.Id,
SkuId = line.SkuId,
SkuCode = line.SkuCode,
PlannedQty = line.PlannedQty,
ReceivedQty = line.ReceivedQty,
BinLocationId = line.BinLocationId
}).ToList()
};
}