ed35362c7a
- Formula Library ratings: thumbs up/down per company per formula; toggle on/off; sorts by net score; own formulas not rateable; FormulaLibraryRating entity + migration AddFormulaLibraryRatings - Job Profitability report: actual labor cost (logged hours x StandardLaborRate) vs powder cost vs billed price per job; gross margin % color-coded; time-tracked-only filter; totals footer - Quote Revision History: track Total price changes on every save; log Sent/Resent events with recipient email; replace flat table with grouped timeline UI (icons per event type, total-change badge on header) - Setup Wizard: cap CompletedCount at TotalSteps so old 10-step data no longer shows 10/5 - Formula Library card: fix badge overflow on long titles; add Rate: label to make voting buttons discoverable - Help docs and AI knowledge base updated for all three features Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
383 lines
16 KiB
C#
383 lines
16 KiB
C#
using AutoMapper;
|
|
using Microsoft.Extensions.Logging;
|
|
using PowderCoating.Application.DTOs.Company;
|
|
using PowderCoating.Application.Interfaces;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Interfaces;
|
|
|
|
namespace PowderCoating.Infrastructure.Services;
|
|
|
|
/// <summary>
|
|
/// Manages the community formula library: sharing a company template to the platform-wide
|
|
/// library, removing it, browsing published entries, and importing an entry as a local copy.
|
|
/// </summary>
|
|
public class FormulaLibraryService : IFormulaLibraryService
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
private readonly IMapper _mapper;
|
|
private readonly ILogger<FormulaLibraryService> _logger;
|
|
|
|
public FormulaLibraryService(IUnitOfWork unitOfWork, IMapper mapper, ILogger<FormulaLibraryService> logger)
|
|
{
|
|
_unitOfWork = unitOfWork;
|
|
_mapper = mapper;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
|
|
int companyId, string? search, string? outputMode, string? industryHint)
|
|
{
|
|
var items = await _unitOfWork.FormulaLibrary.FindAsync(i => i.IsPublished);
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
{
|
|
var lower = search.ToLowerInvariant();
|
|
items = items.Where(i =>
|
|
i.Name.ToLowerInvariant().Contains(lower) ||
|
|
(i.Description != null && i.Description.ToLowerInvariant().Contains(lower)) ||
|
|
(i.Tags != null && i.Tags.ToLowerInvariant().Contains(lower)) ||
|
|
i.SourceCompanyName.ToLowerInvariant().Contains(lower));
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(outputMode))
|
|
items = items.Where(i => i.OutputMode == outputMode);
|
|
|
|
if (!string.IsNullOrWhiteSpace(industryHint))
|
|
items = items.Where(i => i.IndustryHint != null &&
|
|
i.IndustryHint.ToLowerInvariant().Contains(industryHint.ToLowerInvariant()));
|
|
|
|
// Load InspiredBy for attribution line
|
|
var itemList = items.ToList();
|
|
var inspiredByIds = itemList
|
|
.Where(i => i.InspiredByFormulaLibraryItemId.HasValue)
|
|
.Select(i => i.InspiredByFormulaLibraryItemId!.Value)
|
|
.Distinct()
|
|
.ToHashSet();
|
|
|
|
Dictionary<int, FormulaLibraryItem> inspirations = new();
|
|
foreach (var id in inspiredByIds)
|
|
{
|
|
var parent = await _unitOfWork.FormulaLibrary.GetByIdAsync(id);
|
|
if (parent != null) inspirations[id] = parent;
|
|
}
|
|
|
|
// Attach navigation properties manually (PlainRepository doesn't eager-load)
|
|
foreach (var item in itemList)
|
|
{
|
|
if (item.InspiredByFormulaLibraryItemId.HasValue &&
|
|
inspirations.TryGetValue(item.InspiredByFormulaLibraryItemId.Value, out var parent))
|
|
item.InspiredBy = parent;
|
|
}
|
|
|
|
// Determine which entries this company has already imported
|
|
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
|
imp => imp.CompanyId == companyId && !imp.IsDeleted);
|
|
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
|
|
|
|
// Load all ratings in one query for this page of items
|
|
var allItemIds = itemList.Select(i => i.Id).ToHashSet();
|
|
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
|
|
r => allItemIds.Contains(r.FormulaLibraryItemId));
|
|
|
|
// Group counts and find the current company's vote per item
|
|
var ratingsByItem = allRatings
|
|
.GroupBy(r => r.FormulaLibraryItemId)
|
|
.ToDictionary(g => g.Key, g => g.ToList());
|
|
|
|
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
|
|
for (int i = 0; i < dtos.Count; i++)
|
|
{
|
|
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
|
|
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
|
|
|
|
if (ratingsByItem.TryGetValue(dtos[i].Id, out var ratings))
|
|
{
|
|
dtos[i].ThumbsUp = ratings.Count(r => r.IsPositive);
|
|
dtos[i].ThumbsDown = ratings.Count(r => !r.IsPositive);
|
|
var myRating = ratings.FirstOrDefault(r => r.CompanyId == companyId);
|
|
dtos[i].MyVote = myRating?.IsPositive;
|
|
}
|
|
}
|
|
|
|
// Sort: thumbs-up score descending, then import count, then name
|
|
return dtos
|
|
.OrderByDescending(d => d.ThumbsUp - d.ThumbsDown)
|
|
.ThenByDescending(d => d.ImportCount)
|
|
.ThenBy(d => d.Name);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId)
|
|
{
|
|
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
|
if (item == null || !item.IsPublished) return null;
|
|
|
|
if (item.InspiredByFormulaLibraryItemId.HasValue)
|
|
item.InspiredBy = await _unitOfWork.FormulaLibrary.GetByIdAsync(
|
|
item.InspiredByFormulaLibraryItemId.Value);
|
|
|
|
var dto = _mapper.Map<FormulaLibraryDetailDto>(item);
|
|
|
|
var imp = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
|
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
|
|
dto.AlreadyImported = imp.Any();
|
|
|
|
return dto;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request)
|
|
{
|
|
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(request.CustomItemTemplateId);
|
|
if (template == null || template.CompanyId != companyId)
|
|
throw new InvalidOperationException("Template not found.");
|
|
|
|
if (!CanShare(template))
|
|
throw new InvalidOperationException("This template is not eligible to be shared.");
|
|
|
|
// Determine "Inspired by" — if this was imported from the library
|
|
int? inspiredById = null;
|
|
if (template.SourceFormulaLibraryItemId.HasValue && template.IsModifiedFromSource)
|
|
inspiredById = template.SourceFormulaLibraryItemId;
|
|
|
|
// Get company name for attribution
|
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
|
var companyName = company?.CompanyName ?? "Unknown Company";
|
|
|
|
// Re-use existing row if one exists (re-share after unshare, or update after edits)
|
|
var existing = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
|
f => f.SourceCustomItemTemplateId == template.Id && f.SourceCompanyId == companyId);
|
|
|
|
if (existing != null)
|
|
{
|
|
CopyFromTemplate(existing, template, companyName, request);
|
|
existing.InspiredByFormulaLibraryItemId = inspiredById;
|
|
existing.IsPublished = true;
|
|
existing.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.FormulaLibrary.UpdateAsync(existing);
|
|
await _unitOfWork.CompleteAsync();
|
|
return existing.Id;
|
|
}
|
|
|
|
var libraryItem = new FormulaLibraryItem
|
|
{
|
|
SharedByUserId = userId,
|
|
SharedAt = DateTime.UtcNow,
|
|
SourceCustomItemTemplateId = template.Id,
|
|
SourceCompanyId = companyId,
|
|
SourceCompanyName = companyName,
|
|
InspiredByFormulaLibraryItemId = inspiredById,
|
|
IsPublished = true,
|
|
};
|
|
CopyFromTemplate(libraryItem, template, companyName, request);
|
|
|
|
await _unitOfWork.FormulaLibrary.AddAsync(libraryItem);
|
|
await _unitOfWork.CompleteAsync();
|
|
return libraryItem.Id;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task UnshareAsync(int libraryItemId, int companyId)
|
|
{
|
|
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
|
if (item == null || item.SourceCompanyId != companyId) return;
|
|
|
|
item.IsPublished = false;
|
|
item.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.FormulaLibrary.UpdateAsync(item);
|
|
await _unitOfWork.CompleteAsync();
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<int> ImportAsync(int libraryItemId, int companyId, string userId)
|
|
{
|
|
var libraryItem = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
|
if (libraryItem == null || !libraryItem.IsPublished)
|
|
throw new InvalidOperationException("Library entry not found or no longer published.");
|
|
|
|
// Return existing import if already imported
|
|
var existingImports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
|
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
|
|
var existingImport = existingImports.FirstOrDefault();
|
|
if (existingImport != null) return existingImport.ResultingCustomItemTemplateId;
|
|
|
|
// Create a local copy as a new CustomItemTemplate
|
|
var template = new CustomItemTemplate
|
|
{
|
|
CompanyId = companyId,
|
|
Name = libraryItem.Name,
|
|
Description = libraryItem.Description,
|
|
OutputMode = libraryItem.OutputMode,
|
|
FieldsJson = libraryItem.FieldsJson,
|
|
Formula = libraryItem.Formula,
|
|
DefaultRate = libraryItem.DefaultRate,
|
|
RateLabel = libraryItem.RateLabel,
|
|
Notes = libraryItem.Notes,
|
|
DiagramImagePath = libraryItem.DiagramImagePath,
|
|
DisplayOrder = 0,
|
|
IsActive = true,
|
|
SourceFormulaLibraryItemId = libraryItemId,
|
|
IsModifiedFromSource = false,
|
|
};
|
|
|
|
await _unitOfWork.CustomItemTemplates.AddAsync(template);
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
var importRecord = new FormulaLibraryImport
|
|
{
|
|
CompanyId = companyId,
|
|
FormulaLibraryItemId = libraryItemId,
|
|
ImportedByUserId = userId,
|
|
ImportedAt = DateTime.UtcNow,
|
|
ResultingCustomItemTemplateId = template.Id,
|
|
};
|
|
await _unitOfWork.FormulaLibraryImports.AddAsync(importRecord);
|
|
|
|
// Increment import counter
|
|
libraryItem.ImportCount++;
|
|
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
return template.Id;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId)
|
|
{
|
|
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
|
if (template == null || template.CompanyId != companyId)
|
|
return new FormulaLibraryStatusDto { CanShare = false };
|
|
|
|
var dto = new FormulaLibraryStatusDto { CanShare = CanShare(template) };
|
|
|
|
// Populate import attribution
|
|
if (template.SourceFormulaLibraryItemId.HasValue)
|
|
{
|
|
var source = await _unitOfWork.FormulaLibrary.GetByIdAsync(template.SourceFormulaLibraryItemId.Value);
|
|
if (source != null)
|
|
{
|
|
dto.ImportedFromName = source.Name;
|
|
dto.ImportedFromCompany = source.SourceCompanyName;
|
|
}
|
|
}
|
|
|
|
// Check if this template has an active library entry
|
|
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
|
f => f.SourceCustomItemTemplateId == templateId && f.SourceCompanyId == companyId);
|
|
if (libraryItem != null)
|
|
{
|
|
dto.LibraryItemId = libraryItem.Id;
|
|
dto.IsPublished = libraryItem.IsPublished;
|
|
}
|
|
|
|
return dto;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId)
|
|
{
|
|
// Null out on the library item published from this template
|
|
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
|
f => f.SourceCustomItemTemplateId == sourceCustomItemTemplateId);
|
|
if (libraryItem != null && libraryItem.DiagramImagePath != null)
|
|
{
|
|
libraryItem.DiagramImagePath = null;
|
|
libraryItem.UpdatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
|
|
|
|
// Null out on all imported copies
|
|
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
|
i => i.FormulaLibraryItemId == libraryItem.Id && !i.IsDeleted);
|
|
foreach (var imp in imports)
|
|
{
|
|
var copy = await _unitOfWork.CustomItemTemplates.GetByIdAsync(
|
|
imp.ResultingCustomItemTemplateId);
|
|
if (copy != null && copy.DiagramImagePath != null)
|
|
{
|
|
copy.DiagramImagePath = null;
|
|
await _unitOfWork.CompleteAsync();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
|
int libraryItemId, int companyId, bool isPositive)
|
|
{
|
|
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
|
if (item == null || !item.IsPublished)
|
|
throw new InvalidOperationException("Library entry not found.");
|
|
|
|
// Companies cannot rate their own formula
|
|
if (item.SourceCompanyId == companyId)
|
|
throw new InvalidOperationException("You cannot rate your own formula.");
|
|
|
|
var existing = await _unitOfWork.FormulaLibraryRatings.FirstOrDefaultAsync(
|
|
r => r.FormulaLibraryItemId == libraryItemId && r.CompanyId == companyId);
|
|
|
|
bool? myVote;
|
|
if (existing != null && existing.IsPositive == isPositive)
|
|
{
|
|
// Same vote again — toggle off
|
|
await _unitOfWork.FormulaLibraryRatings.DeleteAsync(existing);
|
|
myVote = null;
|
|
}
|
|
else if (existing != null)
|
|
{
|
|
// Opposite vote — flip it
|
|
existing.IsPositive = isPositive;
|
|
existing.RatedAt = DateTime.UtcNow;
|
|
await _unitOfWork.FormulaLibraryRatings.UpdateAsync(existing);
|
|
myVote = isPositive;
|
|
}
|
|
else
|
|
{
|
|
// New vote
|
|
await _unitOfWork.FormulaLibraryRatings.AddAsync(new FormulaLibraryRating
|
|
{
|
|
FormulaLibraryItemId = libraryItemId,
|
|
CompanyId = companyId,
|
|
IsPositive = isPositive,
|
|
RatedAt = DateTime.UtcNow,
|
|
});
|
|
myVote = isPositive;
|
|
}
|
|
|
|
await _unitOfWork.CompleteAsync();
|
|
|
|
// Return fresh counts
|
|
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
|
|
r => r.FormulaLibraryItemId == libraryItemId);
|
|
var list = allRatings.ToList();
|
|
return (list.Count(r => r.IsPositive), list.Count(r => !r.IsPositive), myVote);
|
|
}
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
/// <summary>
|
|
/// A template is shareable if it was created fresh (no source library item) or
|
|
/// if it was imported but then modified by the company.
|
|
/// </summary>
|
|
private static bool CanShare(CustomItemTemplate t) =>
|
|
t.SourceFormulaLibraryItemId == null || t.IsModifiedFromSource;
|
|
|
|
private static void CopyFromTemplate(
|
|
FormulaLibraryItem dest, CustomItemTemplate src, string companyName, ShareFormulaRequest req)
|
|
{
|
|
dest.Name = src.Name;
|
|
dest.Description = src.Description;
|
|
dest.OutputMode = src.OutputMode;
|
|
dest.FieldsJson = src.FieldsJson;
|
|
dest.Formula = src.Formula;
|
|
dest.DefaultRate = src.DefaultRate;
|
|
dest.RateLabel = src.RateLabel;
|
|
dest.Notes = src.Notes;
|
|
dest.DiagramImagePath = src.DiagramImagePath;
|
|
dest.SourceCompanyName = companyName;
|
|
dest.Tags = req.Tags?.Trim();
|
|
dest.IndustryHint = req.IndustryHint?.Trim();
|
|
}
|
|
}
|