using System.Text.Json; using Admin.NET.Plugin.AiDOP.Dto.SmartOps; using Admin.NET.Plugin.AiDOP.Entity; namespace Admin.NET.Plugin.AiDOP.SmartOps; public sealed class KpiTargetService : ITransient { public const int MaxImportRows = 10000; public const long MaxImportBytes = 5 * 1024 * 1024; private readonly IKpiTargetConfigStore _store; private readonly IKpiTargetResolver _resolver; private readonly UserManager _userManager; public KpiTargetService(IKpiTargetConfigStore store, IKpiTargetResolver resolver, UserManager userManager) { _store = store; _resolver = resolver; _userManager = userManager; } public long TenantId => AidopTenantScope.ResolveOrThrow(_userManager); public async Task> ListAsync(string metricCode, long? factoryId, CancellationToken ct = default) { EnsureMetricCode(metricCode); var tenantId = TenantId; await EnsureKpiAsync(tenantId, metricCode, ct); var list = await _store.ListAsync(tenantId, metricCode.Trim(), factoryId, ct); return list.Select(KpiTargetRules.ToDto).ToList(); } public async Task ResolveCurrentAsync(string metricCode, long factoryId, DateTime? bizDate, CancellationToken ct = default) { EnsureMetricCode(metricCode); if (factoryId < 0) throw new KpiTargetValidationException("工厂无效"); var tenantId = TenantId; var kpi = await EnsureKpiAsync(tenantId, metricCode, ct); var day = (bizDate ?? DateTime.Today).Date; var snap = await _resolver.ResolveAsync(tenantId, factoryId, metricCode.Trim(), kpi.ModuleCode, day, ct); return new KpiTargetResolveResult { MetricCode = metricCode.Trim(), FactoryId = factoryId, BizDate = day.ToString("yyyy-MM-dd"), TargetValue = snap.TargetValue, TargetConfigId = snap.TargetConfigId, TargetSource = snap.TargetSource == KpiTargetSnapshotSource.Missing ? KpiTargetSnapshotSource.Missing : snap.TargetSource, ConfigSourceType = snap.TargetConfigId == null ? null : await ConfigSourceAsync(tenantId, snap.TargetConfigId.Value, ct), EffectiveFrom = snap.EffectiveFrom?.ToString("yyyy-MM-dd"), EffectiveTo = snap.EffectiveTo?.ToString("yyyy-MM-dd"), ResolvedAt = snap.ResolvedAt }; } public Task CreateAsync(KpiTargetCreateDto dto, string sourceType, string? batchId, CancellationToken ct = default) => CreateCoreAsync(TenantId, dto, sourceType, batchId, _userManager.UserId, ct); public async Task CreateCoreAsync( long tenantId, KpiTargetCreateDto dto, string sourceType, string? batchId, long? userId, CancellationToken ct = default) { EnsureMetricCode(dto.MetricCode); if (dto.FactoryId < 0) throw new KpiTargetValidationException("工厂无效"); KpiTargetRules.EnsureTargetPositive(dto.TargetValue); var from = KpiTargetRules.Normalize(dto.EffectiveFrom); KpiTargetRules.EnsureRange(from, dto.EffectiveTo); await EnsureKpiAsync(tenantId, dto.MetricCode, ct); var existing = await _store.ListActiveScopeAsync(tenantId, dto.FactoryId, dto.MetricCode.Trim(), ct); if (dto.ClosePrevious) await ClosePreviousOpenAsync(existing, from, ct); var refreshed = await _store.ListActiveScopeAsync(tenantId, dto.FactoryId, dto.MetricCode.Trim(), ct); foreach (var row in refreshed.Where(x => x.Status == 1)) { if (KpiTargetRules.Overlaps(row.EffectiveFrom, row.EffectiveTo, from, dto.EffectiveTo)) throw new KpiTargetConflictException( $"与已有目标重叠:{row.EffectiveFrom:yyyy-MM-dd}~{row.EffectiveTo?.ToString("yyyy-MM-dd") ?? "开放"}"); } var now = DateTime.Now; var created = await _store.InsertAsync(new AdoSmartOpsKpiTargetConfig { TenantId = tenantId, FactoryId = dto.FactoryId, MetricCode = dto.MetricCode.Trim(), TargetValue = dto.TargetValue, EffectiveFrom = from, EffectiveTo = dto.EffectiveTo?.Date, SourceType = sourceType, SourceBatchId = batchId, Status = 1, Remark = dto.Remark, CreatedBy = userId, UpdatedBy = userId, CreateTime = now, UpdateTime = now }, ct); return KpiTargetRules.ToDto(created); } public Task UpdateAsync(long id, KpiTargetUpdateDto dto, CancellationToken ct = default) => UpdateCoreAsync(TenantId, id, dto, ct); public async Task UpdateCoreAsync(long tenantId, long id, KpiTargetUpdateDto dto, CancellationToken ct = default) { var row = await _store.GetByIdAsync(tenantId, id, ct) ?? throw new KpiTargetNotFoundException("目标配置不存在"); var today = DateTime.Today; var alreadyEffective = row.Status == 1 && row.EffectiveFrom.Date <= today; if (dto.TargetValue.HasValue) { if (alreadyEffective && dto.TargetValue.Value != row.TargetValue) throw new KpiTargetConflictException("已生效目标不可原地改值,请新增未来版本"); KpiTargetRules.EnsureTargetPositive(dto.TargetValue.Value); row.TargetValue = dto.TargetValue.Value; } if (dto.EffectiveTo.HasValue) { KpiTargetRules.EnsureRange(row.EffectiveFrom, dto.EffectiveTo); var others = (await _store.ListActiveScopeAsync(tenantId, row.FactoryId, row.MetricCode, ct)) .Where(x => x.Id != row.Id && x.Status == 1); foreach (var other in others) { if (KpiTargetRules.Overlaps(row.EffectiveFrom, dto.EffectiveTo, other.EffectiveFrom, other.EffectiveTo)) throw new KpiTargetConflictException("调整失效日后与其它目标重叠"); } row.EffectiveTo = dto.EffectiveTo.Value.Date; } if (dto.Remark != null) row.Remark = dto.Remark; row.UpdatedBy = _userManager?.UserId; row.UpdateTime = DateTime.Now; await _store.UpdateAsync(row, ct); return KpiTargetRules.ToDto(row); } public async Task DisableAsync(long id, CancellationToken ct = default) { var tenantId = TenantId; var row = await _store.GetByIdAsync(tenantId, id, ct) ?? throw new KpiTargetNotFoundException("目标配置不存在"); if (await _store.IsReferencedByDailyAsync(tenantId, id, ct)) { row.Status = 0; row.EffectiveTo ??= DateTime.Today; row.UpdatedBy = _userManager.UserId; row.UpdateTime = DateTime.Now; await _store.UpdateAsync(row, ct); return; } row.Status = 0; row.UpdatedBy = _userManager.UserId; row.UpdateTime = DateTime.Now; await _store.UpdateAsync(row, ct); } public async Task> LegacyStatsAsync(CancellationToken ct = default) { _ = TenantId; _ = ct; return new List { new() { TargetSource = KpiTargetSnapshotSource.FormalFactory }, new() { TargetSource = KpiTargetSnapshotSource.FormalTenantDefault }, new() { TargetSource = KpiTargetSnapshotSource.LegacyDailyCurrent }, new() { TargetSource = KpiTargetSnapshotSource.LegacyDailyPrior }, new() { TargetSource = KpiTargetSnapshotSource.LegacyCode }, new() { TargetSource = KpiTargetSnapshotSource.Missing } }; } private async Task ClosePreviousOpenAsync(List existing, DateTime newFrom, CancellationToken ct) { var open = existing.Where(x => x.Status == 1 && x.EffectiveTo == null).OrderByDescending(x => x.EffectiveFrom).ToList(); if (open.Count == 0) return; if (open.Count > 1) throw new KpiTargetDuplicateScopeException("同一工厂存在多条开放区间,拒绝静默关闭"); var prev = open[0]; if (prev.EffectiveFrom.Date >= newFrom) throw new KpiTargetConflictException("不允许自动截断未来目标"); var closeTo = newFrom.AddDays(-1); if (closeTo < prev.EffectiveFrom.Date) throw new KpiTargetConflictException("关闭上一目标后区间无效"); prev.EffectiveTo = closeTo; prev.UpdateTime = DateTime.Now; await _store.UpdateAsync(prev, ct); } private async Task EnsureKpiAsync(long tenantId, string metricCode, CancellationToken ct) { var kpi = await _store.FindKpiAsync(tenantId, metricCode.Trim(), ct); if (kpi == null) throw new KpiTargetNotFoundException("指标不存在或不属于当前租户"); return kpi; } private static void EnsureMetricCode(string? metricCode) { if (string.IsNullOrWhiteSpace(metricCode)) throw new KpiTargetValidationException("指标编码必填"); } private async Task ConfigSourceAsync(long tenantId, long configId, CancellationToken ct) { var cfg = await _store.GetByIdAsync(tenantId, configId, ct); return cfg?.SourceType; } public static string SerializePayload(IEnumerable rows) => JsonSerializer.Serialize(rows.ToList()); public static List DeserializePayload(string? json) => string.IsNullOrWhiteSpace(json) ? new List() : JsonSerializer.Deserialize>(json) ?? new List(); }