admin/src/Oms.Application/Services/Warehouse/WarehouseService.Parcel.cs
zzh d1d31fee8a
All checks were successful
Build and Deploy / deploy (push) Successful in 1m43s
fix: 仓储图片上传支持 customerId,9610 明细补并发戳且主从安全
上传接口接受 query customerId;ConcurrencyStamp 默认改为空串并用主键确定性回填,避免 Galera UUID unsafe。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 12:09:36 +08:00

349 lines
13 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.Http;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.Dtos.Shared;
using Oms.Application.Dtos.Warehouse;
using Oms.Application.Shared;
using Oms.Application.Storage;
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/parcel/list")]
public async Task<OmsPageResult<WarehouseParcelOutput>> GetParcelListAsync(WarehouseParcelListInput input)
{
var customerId = await ResolveListCustomerIdAsync(input.CustomerId);
var (hasCustomerFilter, customerIdValue) = CustomerFilter(customerId);
var keyword = input.Keyword?.Trim();
var hasKeyword = !string.IsNullOrWhiteSpace(keyword);
// 前端 Tab「全部」会传 all / 空;需忽略,不能按字面 LifecycleStatus == "all" 过滤
var lifecycleStatus = NormalizeParcelLifecycleStatusFilter(input.LifecycleStatus);
var hasLifecycleStatusFilter = !string.IsNullOrWhiteSpace(lifecycleStatus);
var hasWarehouseFilter = input.WarehouseId.HasValue;
var warehouseIdValue = input.WarehouseId.GetValueOrDefault();
Expression<Func<CParcel, bool>> predicate = hasLifecycleStatusFilter
? x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& x.LifecycleStatus == lifecycleStatus
&& (!hasKeyword || x.TrackingNo.Contains(keyword!) || (x.CarrierName != null && x.CarrierName.Contains(keyword!)))
: x =>
(!hasCustomerFilter || x.CustomerId == customerIdValue)
&& (!hasWarehouseFilter || x.WarehouseId == warehouseIdValue)
&& (!hasKeyword || x.TrackingNo.Contains(keyword!) || (x.CarrierName != null && x.CarrierName.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(
ParcelRepository,
predicate,
pageIndex,
pageSize);
var parcelIds = entities.Select(x => x.Id).ToArray();
var items = parcelIds.Length == 0
? []
: await ParcelItemRepository.GetListAsync(x => parcelIds.Contains(x.ParcelId));
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<WarehouseParcelOutput>
{
Items = entities.Select(x =>
{
var output = MapParcelOutput(x, items.Where(i => i.ParcelId == x.Id));
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? NormalizeParcelLifecycleStatusFilter(string? status)
{
var value = status?.Trim();
if (string.IsNullOrEmpty(value)
|| value.Equals("all", StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (value.Equals(WarehouseParcelLifecycleCodes.InTransit, StringComparison.OrdinalIgnoreCase))
{
return WarehouseParcelLifecycleCodes.InTransit;
}
if (value.Equals(WarehouseParcelLifecycleCodes.Arrived, StringComparison.OrdinalIgnoreCase))
{
return WarehouseParcelLifecycleCodes.Arrived;
}
if (value.Equals(WarehouseParcelLifecycleCodes.Stored, StringComparison.OrdinalIgnoreCase))
{
return WarehouseParcelLifecycleCodes.Stored;
}
return value;
}
/// <summary>
/// 保存包裹(新增或编辑)。
/// </summary>
/// <param name="input">入参:包裹保存数据。</param>
/// <returns>保存后的包裹。</returns>
[HttpPost("/warehouse/parcel/save")]
public async Task<WarehouseParcelOutput> SaveParcelAsync(WarehouseParcelSaveInput input)
{
var customerId = await ResolveMutationCustomerIdAsync(input.CustomerId);
if (input.WarehouseId <= 0)
{
throw new BusinessException("仓库ID不能为空");
}
var trackingNo = input.TrackingNo.Trim();
if (string.IsNullOrWhiteSpace(trackingNo))
{
throw new BusinessException("快递单号不能为空");
}
CParcel parcel;
if (input.Id > 0)
{
parcel = await GetParcelOrThrowAsync(customerId, input.Id);
if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored)
{
throw new BusinessException("已上架包裹不可编辑");
}
}
else
{
parcel = new CParcel { CustomerId = customerId };
}
var duplicate = await ParcelRepository.FirstOrDefaultAsync(x =>
x.CustomerId == customerId
&& x.WarehouseId == input.WarehouseId
&& x.TrackingNo == trackingNo
&& x.Id != parcel.Id);
if (duplicate != null)
{
throw new BusinessException("快递单号已存在");
}
parcel.WarehouseId = input.WarehouseId;
parcel.TrackingNo = trackingNo;
parcel.CarrierName = string.IsNullOrWhiteSpace(input.CarrierName) ? null : input.CarrierName.Trim();
parcel.LifecycleStatus = string.IsNullOrWhiteSpace(input.LifecycleStatus)
? WarehouseParcelLifecycleCodes.InTransit
: input.LifecycleStatus.Trim();
parcel.Remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim();
if (input.Id > 0)
{
await ParcelRepository.UpdateAsync(parcel);
await ClearParcelItemsAsync(parcel.Id);
}
else
{
await ParcelRepository.InsertAsync(parcel);
}
foreach (var itemInput in input.Items)
{
if (itemInput.Quantity <= 0)
{
throw new BusinessException("商品数量必须大于 0");
}
var imageUrl = FileStorage.ToStorageKey(itemInput.ImageUrl);
if (imageUrl != null && imageUrl.Length > 1024)
{
imageUrl = imageUrl[..1024];
}
var item = new CParcelItem
{
ParcelId = parcel.Id,
ProductName = string.IsNullOrWhiteSpace(itemInput.ProductName) ? null : itemInput.ProductName.Trim(),
AttributeText = string.IsNullOrWhiteSpace(itemInput.AttributeText)
? null
: itemInput.AttributeText.Trim(),
ImageUrl = imageUrl,
Quantity = itemInput.Quantity,
FrozenQty = 0,
AvailableQty = itemInput.Quantity,
Remark = string.IsNullOrWhiteSpace(itemInput.Remark) ? null : itemInput.Remark.Trim()
};
await ParcelItemRepository.InsertAsync(item);
}
var savedItems = await ParcelItemRepository.GetListAsync(x => x.ParcelId == parcel.Id);
return MapParcelOutput(parcel, savedItems);
}
/// <summary>
/// 上传包裹商品图片,返回可访问相对路径。
/// </summary>
[HttpPost("/warehouse/parcel/upload-image")]
[Consumes("multipart/form-data")]
public async Task<object> UploadParcelImageAsync(IFormFile file, [FromQuery] int? customerId = null)
{
await ResolveMutationCustomerIdAsync(customerId);
var key = await OmsFileUploadHelper.SaveWarehouseParcelImageAsync(file, FileStorage);
var url = FileStorage.GetPublicUrl(key);
return new { key, url, fileUrl = key };
}
/// <summary>
/// 删除包裹。
/// </summary>
/// <param name="input">入参:包裹 ID。</param>
[HttpPost("/warehouse/parcel/delete")]
public async Task DeleteParcelAsync(IdInput input)
{
var customerId = await ResolveListCustomerIdAsync(null);
var parcel = await GetParcelOrThrowAsync(customerId, input.Id);
if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored)
{
throw new BusinessException("已上架包裹不可删除");
}
await ClearParcelItemsAsync(parcel.Id);
await ParcelRepository.DeleteAsync(parcel);
}
/// <summary>
/// 包裹上架入库。
/// </summary>
/// <param name="input">入参:上架明细(SKU、库位、数量)。</param>
[HttpPost("/warehouse/parcel/putaway")]
public async Task PutawayParcelAsync(WarehouseParcelPutawayInput input)
{
var customerId = await ResolveMutationCustomerIdAsync(input.CustomerId);
var parcel = await GetParcelOrThrowAsync(customerId, input.ParcelId);
if (parcel.LifecycleStatus == WarehouseParcelLifecycleCodes.Stored)
{
throw new BusinessException("包裹已上架");
}
if (input.Items.Count == 0)
{
throw new BusinessException("请指定上架明细");
}
var items = await ParcelItemRepository.GetListAsync(x => x.ParcelId == parcel.Id);
var itemMap = items.ToDictionary(x => x.Id);
foreach (var putawayItem in input.Items)
{
if (!itemMap.TryGetValue(putawayItem.ItemId, out var item))
{
throw new BusinessException($"包裹明细 {putawayItem.ItemId} 不存在");
}
if (putawayItem.SkuId <= 0 || putawayItem.BinLocationId <= 0)
{
throw new BusinessException("SKU 和库位不能为空");
}
var sku = await GetSkuOrThrowAsync(customerId, putawayItem.SkuId);
item.SkuId = sku.Id;
item.SkuCode = sku.SkuCode;
item.BinLocationId = putawayItem.BinLocationId;
await ParcelItemRepository.UpdateAsync(item);
await UpsertInventoryQtyAsync(
customerId,
parcel.WarehouseId,
sku.Id,
putawayItem.BinLocationId,
item.Quantity);
}
parcel.LifecycleStatus = WarehouseParcelLifecycleCodes.Stored;
parcel.ArrivalTime = DateTime.Now;
await ParcelRepository.UpdateAsync(parcel);
}
private async Task<CParcel> GetParcelOrThrowAsync(int? customerId, int id)
{
var entity = customerId.HasValue
? await ParcelRepository.FirstOrDefaultAsync(x => x.Id == id && x.CustomerId == customerId.Value)
: await ParcelRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("包裹不存在");
}
return entity;
}
private async Task ClearParcelItemsAsync(int parcelId)
{
var items = await ParcelItemRepository.GetListAsync(x => x.ParcelId == parcelId);
foreach (var item in items)
{
await ParcelItemRepository.DeleteAsync(item);
}
}
private WarehouseParcelOutput MapParcelOutput(CParcel parcel, IEnumerable<CParcelItem> items)
{
var itemList = items.ToList();
var mappedItems = itemList.Select(item => new WarehouseParcelItemOutput
{
Id = item.Id,
ProductName = item.ProductName,
AttributeText = item.AttributeText,
ImageUrl = FileStorage.GetPublicUrl(item.ImageUrl),
Quantity = item.Quantity,
FrozenQty = item.FrozenQty,
AvailableQty = item.AvailableQty > 0 ? item.AvailableQty : Math.Max(0, item.Quantity - item.FrozenQty),
SkuId = item.SkuId,
SkuCode = item.SkuCode,
BinLocationId = item.BinLocationId,
Remark = item.Remark
}).ToList();
return new WarehouseParcelOutput
{
Id = parcel.Id,
CustomerId = parcel.CustomerId,
WarehouseId = parcel.WarehouseId,
TrackingNo = parcel.TrackingNo,
CarrierName = parcel.CarrierName,
LifecycleStatus = parcel.LifecycleStatus,
ArrivalTime = parcel.ArrivalTime,
Remark = parcel.Remark,
CreateTime = parcel.CreateTime,
TotalQty = mappedItems.Sum(x => x.Quantity),
FrozenQty = mappedItems.Sum(x => x.FrozenQty),
AvailableQty = mappedItems.Sum(x => x.AvailableQty),
Items = mappedItems
};
}
}