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() }; }