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
{
///
/// 查询入库预报列表。
///
/// 入参:入库预报分页查询条件。
/// 入库预报分页结果。
[HttpPost("/warehouse/inbound/list")]
public async Task> 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> 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
{
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
};
}
///
/// 规范化入库状态筛选:空 / all 表示不按状态过滤;已知编码统一为规范值。
///
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;
}
///
/// 查询入库预报详情。
///
/// 入库预报 ID。
/// 客户 ID(可选)。
/// 入库预报详情(含箱与明细)。
[HttpGet("/warehouse/inbound/detail")]
public async Task 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()
};
}
///
/// 保存入库预报(新增或编辑)。编辑仅允许「预报」「待入库」状态;唛头(MarkReference)选填。
///
/// 入参:入库预报保存数据。
/// 保存后的入库预报详情。
[HttpPost("/warehouse/inbound/save")]
public async Task 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);
}
///
/// 删除入库预报。
///
/// 入参:入库预报 ID。
[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);
}
///
/// 确认入库收货并上架。
///
/// 入参:收货箱与实收数量。
[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);
}
///
/// 打印入库箱唛标签:按箱渲染后合并为一个多页 PDF,返回可打印 URL。
///
/// 入参:入库预报 ID(管理端可带 customerId)。
/// printUrl / noticeNo / pageCount。
[HttpPost("/warehouse/inbound/print-labels")]
public async Task 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>(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
};
}
///
/// 查询入库预报状态汇总。
///
/// 客户 ID(可选)。
/// 仓库 ID(可选)。
/// 各状态数量汇总。
[HttpGet("/warehouse/inbound/summary")]
public async Task 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 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 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 boxes,
IReadOnlyCollection 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 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()
};
}