LocationAppService.cs 13.8 KB
using System.IO;
using FoodLabeling.Application.Helpers;
using FoodLabeling.Application.Contracts.Dtos.Location;
using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.IServices;
using FoodLabeling.Application.Options;
using FoodLabeling.Domain.Entities;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Dtos;
using Volo.Abp.Application.Services;
using Volo.Abp.Domain.Repositories;
using Yi.Framework.SqlSugarCore.Abstractions;

namespace FoodLabeling.Application.Services;

/// <summary>
/// 门店管理服务(美国版)
/// </summary>
public class LocationAppService : ApplicationService, ILocationAppService
{
    private readonly ISqlSugarRepository<LocationAggregateRoot, Guid> _locationRepository;
    private readonly ISqlSugarDbContext _dbContext;
    private readonly IOptionsSnapshot<FoodLabelingBatchImportOptions> _batchImportOptions;

    public LocationAppService(
        ISqlSugarRepository<LocationAggregateRoot, Guid> locationRepository,
        ISqlSugarDbContext dbContext,
        IOptionsSnapshot<FoodLabelingBatchImportOptions> batchImportOptions)
    {
        _locationRepository = locationRepository;
        _dbContext = dbContext;
        _batchImportOptions = batchImportOptions;
    }

    /// <inheritdoc />
    public async Task<PagedResultWithPageDto<LocationGetListOutputDto>> GetListAsync([FromQuery] LocationGetListInputVo input)
    {
        RefAsync<int> total = 0;

        var query = await BuildFilteredQueryAsync(input);
        if (!string.IsNullOrWhiteSpace(input.Sorting))
        {
            query = query.OrderBy(input.Sorting);
        }
        else
        {
            query = query.OrderBy(x => x.CreationTime, OrderByType.Desc);
        }

        var entities = await query.ToPageListAsync(input.SkipCount, input.MaxResultCount, total);

        var items = entities.Select(ToListDto).ToList();

        var pageSize = input.MaxResultCount <= 0 ? items.Count : input.MaxResultCount;
        var pageIndex = pageSize <= 0 ? 1 : PagedQueryConvention.PageIndexFromSkipCount(input.SkipCount);
        var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(total.Value / (double)pageSize);

        return new PagedResultWithPageDto<LocationGetListOutputDto>
        {
            PageIndex = pageIndex,
            PageSize = pageSize,
            TotalCount = total.Value,
            TotalPages = totalPages,
            Items = items
        };
    }

    /// <inheritdoc />
    public async Task<LocationGetListOutputDto> CreateAsync([FromBody] LocationCreateInputVo input)
    {
        var locationCode = input.LocationCode?.Trim();
        if (string.IsNullOrWhiteSpace(locationCode))
        {
            throw new UserFriendlyException("Location ID不能为空");
        }

        var locationName = input.LocationName?.Trim();
        if (string.IsNullOrWhiteSpace(locationName))
        {
            throw new UserFriendlyException("Location Name不能为空");
        }

        var isExist = await _locationRepository.IsAnyAsync(x => x.LocationCode == locationCode);
        if (isExist)
        {
            throw new UserFriendlyException("Location ID已存在");
        }

        var entity = new LocationAggregateRoot(GuidGenerator.Create())
        {
            Partner = input.Partner?.Trim(),
            GroupName = input.GroupName?.Trim(),
            LocationCode = locationCode,
            LocationName = locationName,
            Street = input.Street?.Trim(),
            City = input.City?.Trim(),
            StateCode = input.StateCode?.Trim(),
            Country = input.Country?.Trim(),
            ZipCode = input.ZipCode?.Trim(),
            Phone = input.Phone?.Trim(),
            Email = input.Email?.Trim(),
            Latitude = input.Latitude,
            Longitude = input.Longitude,
            OperatingHours = input.OperatingHours?.Trim(),
            State = input.State
        };

        await _locationRepository.InsertAsync(entity);

        return ToListDto(entity);
    }

    /// <inheritdoc />
    public async Task<LocationGetListOutputDto> UpdateAsync(Guid id, [FromBody] LocationUpdateInputVo input)
    {
        var entity = await _locationRepository.GetSingleAsync(x => x.Id == id && x.IsDeleted == false);
        if (entity is null)
        {
            throw new UserFriendlyException("门店不存在");
        }

        var locationName = input.LocationName?.Trim();
        if (string.IsNullOrWhiteSpace(locationName))
        {
            throw new UserFriendlyException("Location Name不能为空");
        }

        entity.Partner = input.Partner?.Trim();
        entity.GroupName = input.GroupName?.Trim();
        entity.LocationName = locationName;
        entity.Street = input.Street?.Trim();
        entity.City = input.City?.Trim();
        entity.StateCode = input.StateCode?.Trim();
        entity.Country = input.Country?.Trim();
        entity.ZipCode = input.ZipCode?.Trim();
        entity.Phone = input.Phone?.Trim();
        entity.Email = input.Email?.Trim();
        entity.Latitude = input.Latitude;
        entity.Longitude = input.Longitude;
        entity.OperatingHours = input.OperatingHours?.Trim();
        entity.State = input.State;

        await _locationRepository.UpdateAsync(entity);

        return ToListDto(entity);
    }

    /// <inheritdoc />
    public async Task DeleteAsync(Guid id)
    {
        var entity = await _locationRepository.GetSingleAsync(x => x.Id == id && x.IsDeleted == false);
        if (entity is null)
        {
            throw new UserFriendlyException("门店不存在");
        }

        entity.IsDeleted = true;
        await _locationRepository.UpdateAsync(entity);
    }

    /// <inheritdoc />
    public Task<IActionResult> DownloadLocationImportTemplateAsync()
    {
        var opt = _batchImportOptions.Value;
        var dir = opt.TemplateDirectory?.Trim();
        if (string.IsNullOrWhiteSpace(dir))
        {
            throw new UserFriendlyException("未配置批量导入模板目录 FoodLabeling:BatchImport:TemplateDirectory");
        }

        var fileName = opt.LocationTemplateFileName?.Trim();
        if (string.IsNullOrWhiteSpace(fileName))
        {
            throw new UserFriendlyException("未配置模板文件名 FoodLabeling:BatchImport:LocationTemplateFileName");
        }

        var fullPath = Path.Combine(dir, fileName);
        if (!File.Exists(fullPath))
        {
            throw new UserFriendlyException($"模板文件不存在:{fullPath}");
        }

        var stream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read);
        const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        return Task.FromResult<IActionResult>(new FileStreamResult(stream, contentType)
        {
            FileDownloadName = fileName
        });
    }

    /// <inheritdoc />
    public async Task<IActionResult> ExportLocationsExcelAsync([FromQuery] LocationGetListInputVo input)
    {
        var exportFilter = new LocationGetListInputVo
        {
            Sorting = input.Sorting,
            Keyword = input.Keyword,
            Partner = input.Partner,
            GroupName = input.GroupName,
            State = input.State
        };

        var query = await BuildFilteredQueryAsync(exportFilter);
        if (!string.IsNullOrWhiteSpace(exportFilter.Sorting))
        {
            query = query.OrderBy(exportFilter.Sorting);
        }
        else
        {
            query = query.OrderBy(x => x.CreationTime, OrderByType.Desc);
        }

        var entities = await query.ToListAsync();
        var rows = entities.Select(ToListDto).ToList();

        var ms = LocationBatchExcelHelper.BuildExportWorkbook(rows);
        const string contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        var downloadName = $"locations-export-{Clock.Now:yyyyMMdd-HHmmss}.xlsx";
        return new FileStreamResult(ms, contentType) { FileDownloadName = downloadName };
    }

    /// <inheritdoc />
    public async Task<LocationBatchImportResultDto> ImportLocationsBatchAsync([FromForm] LocationBatchImportInputVo input)
    {
        if (input?.File is null || input.File.Length == 0)
        {
            throw new UserFriendlyException("请上传 Excel 文件(form 字段名:file)");
        }

        var opt = _batchImportOptions.Value;
        if (input.File.Length > opt.MaxUploadBytes)
        {
            throw new UserFriendlyException($"文件过大,最大允许 {opt.MaxUploadBytes / 1024 / 1024} MB");
        }

        var ext = Path.GetExtension(input.File.FileName)?.ToLowerInvariant();
        if (ext != ".xlsx")
        {
            throw new UserFriendlyException("仅支持 .xlsx 格式的 Excel 文件");
        }

        await using var uploadStream = input.File.OpenReadStream();
        var parseErrors = new List<LocationBatchImportErrorDto>();
        var rows = LocationBatchExcelHelper.ParseImportWorkbook(
            uploadStream,
            opt.MaxImportRows <= 0 ? 5000 : opt.MaxImportRows,
            out var headerErrors);
        parseErrors.AddRange(headerErrors);

        var result = new LocationBatchImportResultDto();
        if (rows.Count == 0 && parseErrors.Count > 0)
        {
            result.Errors = parseErrors;
            result.FailCount = parseErrors.Count;
            return result;
        }

        foreach (var (rowNum, vo) in rows)
        {
            try
            {
                await CreateAsync(vo);
                result.SuccessCount++;
            }
            catch (UserFriendlyException ex)
            {
                result.FailCount++;
                result.Errors.Add(new LocationBatchImportErrorDto
                {
                    RowNumber = rowNum,
                    LocationCode = vo.LocationCode,
                    Message = ex.Message
                });
            }
        }

        result.Errors.InsertRange(0, parseErrors);
        return result;
    }

    /// <inheritdoc />
    public async Task<LocationBulkUpdateResultDto> UpdateLocationsBulkAsync([FromBody] LocationBulkUpdateInputVo input)
    {
        if (input?.Items is null || input.Items.Count == 0)
        {
            throw new UserFriendlyException("请至少提交一条编辑数据(items 不能为空)");
        }

        var opt = _batchImportOptions.Value;
        var maxItems = opt.MaxBulkUpdateItems <= 0 ? 500 : opt.MaxBulkUpdateItems;
        if (input.Items.Count > maxItems)
        {
            throw new UserFriendlyException($"单次批量编辑最多允许 {maxItems} 条,请分批提交");
        }

        var effectiveCount = input.Items.Count(static x => x is not null && x.Id != Guid.Empty);
        if (effectiveCount == 0)
        {
            throw new UserFriendlyException("没有有效的门店 Id(请为待保存行填写 id,空行请使用 id 为空 GUID 或从列表中移除)");
        }

        var result = new LocationBulkUpdateResultDto();
        for (var i = 0; i < input.Items.Count; i++)
        {
            var item = input.Items[i];
            if (item is null || item.Id == Guid.Empty)
            {
                continue;
            }

            try
            {
                await UpdateAsync(item.Id, item);
                result.SuccessCount++;
            }
            catch (UserFriendlyException ex)
            {
                result.FailCount++;
                result.Errors.Add(new LocationBulkUpdateErrorDto
                {
                    RowNumber = i + 1,
                    Id = item.Id,
                    Message = ex.Message
                });
            }
        }

        return result;
    }

    private async Task<ISugarQueryable<LocationAggregateRoot>> BuildFilteredQueryAsync(LocationGetListInputVo input)
    {
        var scope = await LocationRegionScopeHelper.ResolveLocationListScopeAsync(CurrentUser, _dbContext);

        var keyword = input.Keyword?.Trim();
        var partner = input.Partner?.Trim();
        var groupName = input.GroupName?.Trim();

        var query = _locationRepository._DbQueryable
            .Where(x => x.IsDeleted == false);

        query = LocationRegionScopeHelper.ApplyLocationListScope(query, scope);

        return query
            .WhereIF(!string.IsNullOrEmpty(partner), x => x.Partner == partner)
            .WhereIF(!string.IsNullOrEmpty(groupName), x => x.GroupName == groupName)
            .WhereIF(input.State is not null, x => x.State == input.State)
            .WhereIF(!string.IsNullOrEmpty(keyword),
                x =>
                    x.LocationCode.Contains(keyword!) ||
                    x.LocationName.Contains(keyword!) ||
                    (x.Street != null && x.Street.Contains(keyword!)) ||
                    (x.City != null && x.City.Contains(keyword!)) ||
                    (x.StateCode != null && x.StateCode.Contains(keyword!)) ||
                    (x.Country != null && x.Country.Contains(keyword!)) ||
                    (x.ZipCode != null && x.ZipCode.Contains(keyword!)) ||
                    (x.Phone != null && x.Phone.Contains(keyword!)) ||
                    (x.Email != null && x.Email.Contains(keyword!)) ||
                    (x.OperatingHours != null && x.OperatingHours.Contains(keyword!)));
    }

    private static LocationGetListOutputDto ToListDto(LocationAggregateRoot x) =>
        new()
        {
            Id = x.Id,
            Partner = x.Partner,
            GroupName = x.GroupName,
            LocationCode = x.LocationCode,
            LocationName = x.LocationName,
            Street = x.Street,
            City = x.City,
            StateCode = x.StateCode,
            Country = x.Country,
            ZipCode = x.ZipCode,
            Phone = x.Phone,
            Email = x.Email,
            Latitude = x.Latitude,
            Longitude = x.Longitude,
            OperatingHours = x.OperatingHours,
            State = x.State
        };
}