All checks were successful
Build and Deploy / deploy (push) Successful in 1m43s
上传接口接受 query customerId;ConcurrencyStamp 默认改为空串并用主键确定性回填,避免 Galera UUID unsafe。 Co-authored-by: Cursor <cursoragent@cursor.com>
349 lines
13 KiB
C#
349 lines
13 KiB
C#
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
|
||
};
|
||
}
|
||
}
|