LabelAlertTimerAppService.cs 14.3 KB
using FoodLabeling.Application.Contracts.Dtos.Common;
using FoodLabeling.Application.Contracts.Dtos.LabelAlertTimer;
using FoodLabeling.Application.Contracts.IServices;
using FoodLabeling.Application.Helpers;
using FoodLabeling.Application.Services.DbModels;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Distributed;
using SqlSugar;
using Volo.Abp;
using Volo.Abp.Application.Services;
using Yi.Framework.SqlSugarCore.Abstractions;

namespace FoodLabeling.Application.Services;

/// <summary>
/// 标签告警计时器(App):跟踪已打印标签的过期倒计时;与可否打印无关。
/// </summary>
public class LabelAlertTimerAppService : ApplicationService, ILabelAlertTimerAppService
{
    private const string StatusRunning = "running";
    private const string StatusExpired = "expired";

    private readonly ISqlSugarDbContext _dbContext;
    private readonly IDistributedCache _distributedCache;

    public LabelAlertTimerAppService(ISqlSugarDbContext dbContext, IDistributedCache distributedCache)
    {
        _dbContext = dbContext;
        _distributedCache = distributedCache;
    }

    /// <summary>
    /// 分页查询当前门店告警计时器列表(含倒计时)
    /// </summary>
    /// <remarks>
    /// 仅展示已打印标签的过期倒计时,<b>不</b>用于判断能否打印。
    /// 过期时刻与 Print Log「Expiration」列同源(<c>ReportsPrintLogExpiryHelper</c>)。
    /// 同一打印批次(<c>BatchId</c>)无论打印多少张标签,仅一条计时器(取 CopyIndex 最小任务)。
    ///
    /// 示例请求:
    /// ```json
    /// {
    ///   "locationId": "11111111-1111-1111-1111-111111111111",
    ///   "skipCount": 1,
    ///   "maxResultCount": 20,
    ///   "dateDay": "2026-08-07"
    /// }
    /// ```
    ///
    /// 参数说明:
    /// - locationId: 当前门店 Id(必填,须已绑定)
    /// - skipCount: 页码(从 1 开始)
    /// - maxResultCount: 每页条数
    /// - dateDay: 可选,按 PrintedAt 自然日筛选(yyyy-MM-dd)
    /// </remarks>
    /// <param name="input">分页查询入参</param>
    /// <returns>分页计时器列表(含 remainingTime 倒计时秒数)</returns>
    /// <response code="200">成功返回分页列表</response>
    /// <response code="400">参数错误或未登录/无门店权限</response>
    /// <response code="500">服务器错误</response>
    [Authorize]
    [HttpPost("label-alert-timer/list")]
    public virtual async Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> GetListAsync(
        LabelAlertTimerGetListInputVo input)
    {
        if (input is null)
        {
            throw new UserFriendlyException("入参不能为空");
        }

        if (!CurrentUser.Id.HasValue)
        {
            throw new UserFriendlyException("用户未登录");
        }

        var locationId = input.LocationId?.Trim();
        if (string.IsNullOrWhiteSpace(locationId))
        {
            throw new UserFriendlyException("门店Id不能为空");
        }

        return await QueryListByLocationAsync(locationId, input);
    }

    /// <summary>
    /// App:当前账号当前门店告警列表(含倒计时)
    /// </summary>
    /// <remarks>
    /// 供 App 警告页使用:按当前登录账号可访问的门店查询已打印标签的告警倒计时。
    /// <c>locationId</c> 可省略,省略时使用管理员已选门店缓存(<c>select-admin-scope-location</c>);
    /// 仍无门店时返回友好错误。过期状态仅用于展示,与可否打印无关。
    ///
    /// 示例请求:
    /// ```json
    /// {
    ///   "locationId": "11111111-1111-1111-1111-111111111111",
    ///   "skipCount": 1,
    ///   "maxResultCount": 50
    /// }
    /// ```
    ///
    /// 参数说明:
    /// - locationId: 当前门店 Id(可选;空则取已选门店缓存)
    /// - skipCount: 页码(从 1 开始)
    /// - maxResultCount: 每页条数
    /// - dateDay: 可选,按 PrintedAt 自然日筛选(yyyy-MM-dd)
    ///
    /// 出参倒计时字段:
    /// - remainingTime: 剩余秒数,App 可直接做倒计时
    /// - totalTime: 总时长(秒)
    /// - status: running / expired
    /// - expiresAt: 过期时刻
    /// </remarks>
    /// <param name="input">分页查询入参</param>
    /// <returns>分页告警列表(含倒计时)</returns>
    /// <response code="200">成功返回分页列表</response>
    /// <response code="400">未登录、无门店或无权限</response>
    /// <response code="500">服务器错误</response>
    [Authorize]
    [HttpPost("label-alert-timer/app-list")]
    public virtual async Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> GetAppListAsync(
        LabelAlertTimerGetListInputVo input)
    {
        if (input is null)
        {
            throw new UserFriendlyException("入参不能为空");
        }

        if (!CurrentUser.Id.HasValue)
        {
            throw new UserFriendlyException("用户未登录");
        }

        var locationId = input.LocationId?.Trim();
        if (string.IsNullOrWhiteSpace(locationId))
        {
            var cache = await UsAppAuthScopeHelper.GetAdminScopeCacheAsync(
                _distributedCache,
                CurrentUser.Id.Value);
            locationId = cache?.Location?.Id?.Trim();
        }

        if (string.IsNullOrWhiteSpace(locationId))
        {
            throw new UserFriendlyException("请先选择门店或传入 locationId");
        }

        return await QueryListByLocationAsync(locationId, input);
    }

    /// <summary>
    /// 软删除告警计时器
    /// </summary>
    /// <remarks>
    /// 删除前校验当前用户可访问该计时器所属门店。
    /// </remarks>
    /// <param name="id">计时器 Id</param>
    /// <response code="200">删除成功</response>
    /// <response code="400">记录不存在或无门店权限</response>
    /// <response code="500">服务器错误</response>
    [Authorize]
    [HttpDelete("label-alert-timer/{id}")]
    public virtual async Task DeleteAsync(string id)
    {
        var timerId = id?.Trim();
        if (string.IsNullOrWhiteSpace(timerId))
        {
            throw new UserFriendlyException("计时器Id不能为空");
        }

        if (!CurrentUser.Id.HasValue)
        {
            throw new UserFriendlyException("用户未登录");
        }

        var db = _dbContext.SqlSugarClient;
        var row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
                .Where(x => x.Id == timerId && !x.IsDeleted)
                .Take(1)
                .ToListAsync())
            .FirstOrDefault();
        if (row is null)
        {
            throw new UserFriendlyException("计时器不存在或已删除");
        }

        await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync(
            CurrentUser, db, row.LocationId);

        var now = DateTime.Now;
        await db.Updateable<FlLabelAlertTimerDbEntity>()
            .SetColumns(x => x.IsDeleted == true)
            .SetColumns(x => x.DeletionTime == now)
            .Where(x => x.Id == timerId && !x.IsDeleted)
            .ExecuteCommandAsync();
    }

    /// <summary>
    /// 查询已打印标签告警的过期/倒计时状态(不拦截打印)
    /// </summary>
    /// <remarks>
    /// 至少提供 <c>timerId</c>、<c>batchId</c>、<c>printTaskId</c> 之一。
    /// 本接口仅返回已打印批次的过期状态与剩余秒数,供展示倒计时;
    /// <b>绝不</b>用于判断「能不能打印」——打印流程不得依赖本接口结果做拦截。
    ///
    /// 示例请求:
    /// ```json
    /// {
    ///   "batchId": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
    /// }
    /// ```
    ///
    /// 参数说明:
    /// - timerId: 计时器 Id
    /// - batchId: 打印批次 Id
    /// - printTaskId: 打印任务 Id(同批次任意任务均可)
    /// </remarks>
    /// <param name="input">查询入参</param>
    /// <returns>过期/倒计时状态(展示用)</returns>
    /// <response code="200">成功(含未找到记录的情况)</response>
    /// <response code="400">未提供任何标识</response>
    /// <response code="500">服务器错误</response>
    [Authorize]
    [HttpPost("label-alert-timer/check-expired")]
    public virtual async Task<LabelAlertTimerCheckExpiredOutputDto> CheckExpiredAsync(
        LabelAlertTimerCheckExpiredInputVo input)
    {
        if (input is null)
        {
            throw new UserFriendlyException("入参不能为空");
        }

        var timerId = input.TimerId?.Trim();
        var batchId = input.BatchId?.Trim();
        var printTaskId = input.PrintTaskId?.Trim();
        if (string.IsNullOrWhiteSpace(timerId) &&
            string.IsNullOrWhiteSpace(batchId) &&
            string.IsNullOrWhiteSpace(printTaskId))
        {
            throw new UserFriendlyException("请至少提供 timerId、batchId 或 printTaskId 之一");
        }

        var db = _dbContext.SqlSugarClient;
        FlLabelAlertTimerDbEntity? row = null;

        if (!string.IsNullOrWhiteSpace(timerId))
        {
            row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
                    .Where(x => x.Id == timerId && !x.IsDeleted)
                    .Take(1)
                    .ToListAsync())
                .FirstOrDefault();
        }
        else if (!string.IsNullOrWhiteSpace(batchId))
        {
            row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
                    .Where(x => x.BatchId == batchId && !x.IsDeleted)
                    .Take(1)
                    .ToListAsync())
                .FirstOrDefault();
        }
        else if (!string.IsNullOrWhiteSpace(printTaskId))
        {
            var task = (await db.Queryable<FlLabelPrintTaskDbEntity>()
                    .Where(x => x.Id == printTaskId)
                    .Take(1)
                    .ToListAsync())
                .FirstOrDefault();
            if (task is not null && !string.IsNullOrWhiteSpace(task.BatchId))
            {
                row = (await db.Queryable<FlLabelAlertTimerDbEntity>()
                        .Where(x => x.BatchId == task.BatchId && !x.IsDeleted)
                        .Take(1)
                        .ToListAsync())
                    .FirstOrDefault();
            }
        }

        if (row is null)
        {
            return new LabelAlertTimerCheckExpiredOutputDto
            {
                Found = false,
                IsExpired = false,
                RemainingSeconds = 0
            };
        }

        if (!CurrentUser.Id.HasValue)
        {
            throw new UserFriendlyException("用户未登录");
        }

        await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync(
            CurrentUser, db, row.LocationId);

        var now = DateTime.Now;
        var remaining = Math.Max(0, (int)(row.ExpiresAt - now).TotalSeconds);
        var isExpired = row.ExpiresAt <= now;

        return new LabelAlertTimerCheckExpiredOutputDto
        {
            Found = true,
            IsExpired = isExpired,
            ExpiresAt = row.ExpiresAt,
            RemainingSeconds = remaining,
            Status = isExpired ? StatusExpired : StatusRunning,
            Title = row.Title,
            Subtitle = row.Subtitle,
            TimerId = row.Id,
            BatchId = row.BatchId
        };
    }

    private async Task<PagedResultWithPageDto<LabelAlertTimerListItemDto>> QueryListByLocationAsync(
        string locationId,
        LabelAlertTimerGetListInputVo input)
    {
        await UsAppPrintLogScopeHelper.EnsureUserCanAccessLocationAsync(
            CurrentUser, _dbContext.SqlSugarClient, locationId);

        var db = _dbContext.SqlSugarClient;
        RefAsync<int> total = 0;
        var query = db.Queryable<FlLabelAlertTimerDbEntity>()
            .Where(x => !x.IsDeleted && x.LocationId == locationId);

        var (dayStart, dayEndExcl) = ResolveDateDayFilter(input.DateDay);
        if (dayStart.HasValue && dayEndExcl.HasValue)
        {
            var start = dayStart.Value;
            var endExcl = dayEndExcl.Value;
            query = query.Where(x => x.PrintedAt >= start && x.PrintedAt < endExcl);
        }

        var pageRows = await query
            .OrderBy(x => x.ExpiresAt, OrderByType.Desc)
            .OrderBy(x => x.PrintedAt, OrderByType.Desc)
            .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);

        var now = DateTime.Now;
        var items = pageRows.Select(x => MapListItem(x, now)).ToList();

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

        return new PagedResultWithPageDto<LabelAlertTimerListItemDto>
        {
            PageIndex = pageIndex,
            PageSize = pageSize,
            TotalCount = totalCount,
            TotalPages = totalPages,
            Items = items
        };
    }

    private static LabelAlertTimerListItemDto MapListItem(FlLabelAlertTimerDbEntity row, DateTime now)
    {
        var remaining = Math.Max(0, (int)(row.ExpiresAt - now).TotalSeconds);
        var isExpired = row.ExpiresAt <= now;

        return new LabelAlertTimerListItemDto
        {
            Id = row.Id,
            BatchId = row.BatchId,
            PrintTaskId = row.PrintTaskId,
            LabelId = row.LabelId,
            LabelCode = row.LabelCode,
            Title = row.Title,
            Subtitle = row.Subtitle,
            TotalTime = row.DurationSeconds,
            RemainingTime = remaining,
            Status = isExpired ? StatusExpired : StatusRunning,
            ExpiresAt = row.ExpiresAt,
            PrintedAt = row.PrintedAt,
            LocationId = row.LocationId,
            ProductName = row.ProductName
        };
    }

    private static (DateTime? DayStart, DateTime? DayEndExcl) ResolveDateDayFilter(string? dateDay)
    {
        if (string.IsNullOrWhiteSpace(dateDay))
        {
            return (null, null);
        }

        if (DateTime.TryParse(dateDay.Trim(), out var parsedDay))
        {
            var day = parsedDay.Date;
            return (day, day.AddDays(1));
        }

        return (null, null);
    }
}