Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 | |||
| 03b425a12f | |||
| 8453449833 | |||
| ad986561c9 | |||
| 0d5553f3b2 | |||
| 87bbf158a4 | |||
| f453a95f28 | |||
| d9e98a55d2 | |||
| 99deca3b62 | |||
| 23e64829bb | |||
| cd4c233b60 | |||
| 6c07216c64 | |||
| b23bea6db0 | |||
| cf07356147 | |||
| 39b103a482 | |||
| 4aae2df5b5 | |||
| 3416c242f1 | |||
| 7e31846777 | |||
| ed35362c7a | |||
| 81119035c7 | |||
| 0deef574c3 | |||
| efc4e9dadf | |||
| ca7e905832 | |||
| 32d09b38f1 | |||
| 3cee1307fc | |||
| be89327c01 | |||
| 8f955851e5 | |||
| 972123c7a2 | |||
| 9dd36238bb | |||
| 8ae61b6c78 | |||
| 97745f9a65 | |||
| e124fd5c8b | |||
| 6c2fe6e1c4 | |||
| f625be01a3 | |||
| e6c4cfb38b | |||
| 5b5247624c | |||
| 91a176ce5c | |||
| a7ad0e1de8 | |||
| e4a256a6c4 | |||
| e476b4744d | |||
| 04d16109ae | |||
| f0f3717681 | |||
| e23b006139 | |||
| 0f35946973 | |||
| 19e1ce858f | |||
| 026e646295 | |||
| b7fcefa765 | |||
| 1722cd4124 | |||
| c3742e1585 | |||
| 1a6f855c05 | |||
| d28e639d1b | |||
| 10f668fd73 | |||
| 19b7a9a473 | |||
| 4650ba3d4d | |||
| 1eba50cf0f | |||
| e443457139 | |||
| edf56c1164 | |||
| b9cd693421 | |||
| d77b3778ac | |||
| a7bf97a2df | |||
| 05935b110a | |||
| 64a9c1531b | |||
| f018653c18 | |||
| b7ab85ff92 | |||
| 15b070398b | |||
| 14f220347b | |||
| baec0b33f7 |
@@ -0,0 +1,11 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!--
|
||||||
|
NCalc2 2.1.0 -> Antlr4 4.6.4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 pulls in
|
||||||
|
old package versions that trigger NU1605 downgrade warnings when publishing for linux-x64.
|
||||||
|
These are harmless false positives — .NET 8 supplies all of these natively at runtime.
|
||||||
|
Suppressing NU1605 here is cleaner than pinning every affected transitive package individually.
|
||||||
|
-->
|
||||||
|
<NoWarn>$(NoWarn);NU1605</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
||||||
public bool HasCurrentSmsAgreement { get; set; }
|
public bool HasCurrentSmsAgreement { get; set; }
|
||||||
public string SmsTermsVersion { get; set; } = string.Empty;
|
public string SmsTermsVersion { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Timeclock settings
|
||||||
|
public bool TimeclockEnabled { get; set; }
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
|
||||||
|
public class UpdateTimeclockSettingsDto
|
||||||
|
{
|
||||||
|
public bool TimeclockEnabled { get; set; }
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||||
|
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// LIST DTO - For Company Settings tab table
|
||||||
|
// ============================================================================
|
||||||
|
public class CustomItemTemplateListDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
public int FieldCount { get; set; }
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FULL DTO - For Edit modal and formula evaluation
|
||||||
|
// ============================================================================
|
||||||
|
public class CustomItemTemplateDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CREATE DTO
|
||||||
|
// ============================================================================
|
||||||
|
public class CreateCustomItemTemplateDto
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"FixedRate" or "SurfaceAreaSqFt"</summary>
|
||||||
|
[Required]
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
|
||||||
|
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||||
|
[Required]
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// UPDATE DTO
|
||||||
|
// ============================================================================
|
||||||
|
public class UpdateCustomItemTemplateDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
|
||||||
|
[StringLength(1000)]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Existing diagram path — kept if no new file is uploaded.</summary>
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
|
||||||
|
// ============================================================================
|
||||||
|
public class CustomItemTemplatePickerDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// AI GENERATION DTOs
|
||||||
|
// ============================================================================
|
||||||
|
public class GenerateFormulaFromAiRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GenerateFormulaFromAiResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? OutputMode { get; set; }
|
||||||
|
public string? FieldsJson { get; set; }
|
||||||
|
public string? Formula { get; set; }
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? Reasoning { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Result of running the formula with any sample values found in the description.</summary>
|
||||||
|
public decimal? VerificationResult { get; set; }
|
||||||
|
public string? VerificationInputs { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// FORMULA EVALUATION DTOs
|
||||||
|
// ============================================================================
|
||||||
|
public class EvaluateFormulaRequest
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}</summary>
|
||||||
|
[Required]
|
||||||
|
public string VariablesJson { get; set; } = "{}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EvaluateFormulaResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public decimal? Result { get; set; }
|
||||||
|
public string? Error { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
namespace PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
// ── Browse / card display ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Lean DTO for the community library browse grid card.</summary>
|
||||||
|
public class FormulaLibraryCardDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? IndustryHint { get; set; }
|
||||||
|
public string SourceCompanyName { get; set; } = string.Empty;
|
||||||
|
public int ImportCount { get; set; }
|
||||||
|
public DateTime SharedAt { get; set; }
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Non-null when this formula was derived from another library entry.</summary>
|
||||||
|
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||||
|
public string? InspiredByName { get; set; }
|
||||||
|
public string? InspiredByCompanyName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the current company has already imported this entry.</summary>
|
||||||
|
public bool AlreadyImported { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when this formula was shared by the current browsing company.</summary>
|
||||||
|
public bool IsOwnFormula { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total thumbs-up votes across all companies.</summary>
|
||||||
|
public int ThumbsUp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total thumbs-down votes across all companies.</summary>
|
||||||
|
public int ThumbsDown { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The current browsing company's vote: true = up, false = down, null = no vote.</summary>
|
||||||
|
public bool? MyVote { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full detail (import preview modal) ────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Full DTO used in the import preview modal — shows fields and formula.</summary>
|
||||||
|
public class FormulaLibraryDetailDto : FormulaLibraryCardDto
|
||||||
|
{
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int FieldCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share from Company Settings ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Submitted when a company admin shares one of their templates to the community library.</summary>
|
||||||
|
public class ShareFormulaRequest
|
||||||
|
{
|
||||||
|
public int CustomItemTemplateId { get; set; }
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? IndustryHint { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Company Settings list view ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Status of a template relative to the community library, shown in Company Settings.</summary>
|
||||||
|
public class FormulaLibraryStatusDto
|
||||||
|
{
|
||||||
|
/// <summary>The FormulaLibraryItem Id, if this template has ever been shared.</summary>
|
||||||
|
public int? LibraryItemId { get; set; }
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this template is eligible to be shared (original or modified import).</summary>
|
||||||
|
public bool CanShare { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set when this template was imported; the name of the original library entry.</summary>
|
||||||
|
public string? ImportedFromName { get; set; }
|
||||||
|
public string? ImportedFromCompany { get; set; }
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public class EquipmentDto
|
|||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
public int? DaysUntilMaintenance { get; set; }
|
public int? DaysUntilMaintenance { get; set; }
|
||||||
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
|
|||||||
|
|
||||||
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
||||||
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Last Maintenance Date")]
|
[Display(Name = "Last Maintenance Date")]
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ public class JobImportDto
|
|||||||
[Name("CustomerName")]
|
[Name("CustomerName")]
|
||||||
public string? CustomerName { get; set; }
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
// Optional short label for the job (maps directly to Job.Description).
|
||||||
|
// When blank, the system falls back to SpecialInstructions, then "Imported job".
|
||||||
|
[Name("Description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
[Name("Status")]
|
[Name("Status")]
|
||||||
public string Status { get; set; } = "Pending";
|
public string Status { get; set; } = "Pending";
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? ExternalReference { get; set; }
|
public string? ExternalReference { get; set; }
|
||||||
public int? SalesTaxAccountId { get; set; }
|
public int? SalesTaxAccountId { get; set; }
|
||||||
public string? SalesTaxAccountName { get; set; }
|
public string? SalesTaxAccountName { get; set; }
|
||||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public class JobDto
|
|||||||
public decimal DiscountValue { get; set; }
|
public decimal DiscountValue { get; set; }
|
||||||
public string? DiscountReason { get; set; }
|
public string? DiscountReason { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
@@ -113,6 +114,8 @@ public class JobListDto
|
|||||||
|
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||||
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public DateTime? ScheduledDate { get; set; }
|
public DateTime? ScheduledDate { get; set; }
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
@@ -166,6 +169,7 @@ public class CreateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
@@ -251,6 +255,7 @@ public class UpdateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
@@ -325,7 +330,11 @@ public class JobItemDto
|
|||||||
public bool IsGenericItem { get; set; }
|
public bool IsGenericItem { get; set; }
|
||||||
public bool IsLaborItem { get; set; }
|
public bool IsLaborItem { get; set; }
|
||||||
public bool IsSalesItem { get; set; }
|
public bool IsSalesItem { get; set; }
|
||||||
|
public bool IsAiItem { get; set; }
|
||||||
public string? Sku { get; set; }
|
public string? Sku { get; set; }
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
public List<JobItemCoatDto> Coats { get; set; } = new();
|
public List<JobItemCoatDto> Coats { get; set; } = new();
|
||||||
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
||||||
}
|
}
|
||||||
@@ -486,6 +495,7 @@ public class ReworkRecordDto
|
|||||||
public decimal ActualReworkCost { get; set; }
|
public decimal ActualReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
@@ -511,6 +521,11 @@ public class CreateReworkRecordDto
|
|||||||
public decimal EstimatedReworkCost { get; set; }
|
public decimal EstimatedReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Rework job creation (opt-in)
|
||||||
|
public bool CreateReworkJob { get; set; }
|
||||||
|
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateReworkRecordDto
|
public class UpdateReworkRecordDto
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
@@ -475,6 +478,11 @@ public class QuoteItemDto
|
|||||||
|
|
||||||
public bool IsAiItem { get; set; }
|
public bool IsAiItem { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Cost breakdown snapshot
|
// Cost breakdown snapshot
|
||||||
public decimal ItemMaterialCost { get; set; }
|
public decimal ItemMaterialCost { get; set; }
|
||||||
public decimal ItemLaborCost { get; set; }
|
public decimal ItemLaborCost { get; set; }
|
||||||
@@ -559,6 +567,11 @@ public class CreateQuoteItemDto
|
|||||||
|
|
||||||
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -874,4 +887,9 @@ public class QuotePricingResult
|
|||||||
|
|
||||||
// Per-item results (same order as input items)
|
// Per-item results (same order as input items)
|
||||||
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
||||||
|
|
||||||
|
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
|
||||||
|
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
|
||||||
|
public decimal CustomPowderOrderAmount { get; set; }
|
||||||
|
public List<string> CustomPowderOrderColors { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
|
|||||||
public bool AllowAiInventoryAssist { get; set; }
|
public bool AllowAiInventoryAssist { get; set; }
|
||||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||||
public bool AllowSms { get; set; }
|
public bool AllowSms { get; set; }
|
||||||
|
public bool AllowCustomFormulas { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
|
|||||||
public bool AllowAiInventoryAssist { get; set; }
|
public bool AllowAiInventoryAssist { get; set; }
|
||||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||||
public bool AllowSms { get; set; }
|
public bool AllowSms { get; set; }
|
||||||
|
public bool AllowCustomFormulas { get; set; }
|
||||||
|
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Timeclock;
|
||||||
|
|
||||||
|
public class EmployeeClockEntryDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string UserDisplayName { get; set; } = string.Empty;
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public decimal? HoursWorked { get; set; }
|
||||||
|
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsOpen => ClockOutTime == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClockInRequest
|
||||||
|
{
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClockOutRequest
|
||||||
|
{
|
||||||
|
public int EntryId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
|
||||||
|
/// The server determines whether to clock in or clock out based on the employee's open entry.
|
||||||
|
/// </summary>
|
||||||
|
public class KioskPunchRequest
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string Pin { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EditClockEntryRequest
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sent when an employee clicks Break or Lunch to pause their work segment.
|
||||||
|
/// The server closes the current Work entry and opens a Break/Lunch entry.
|
||||||
|
/// </summary>
|
||||||
|
public class GoOnBreakRequest
|
||||||
|
{
|
||||||
|
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
|
||||||
|
public ClockEntryType BreakType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
|
||||||
|
public class ManualEntryRequest
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
|
||||||
|
public class KioskEmployeeDto
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string Initials { get; set; } = string.Empty;
|
||||||
|
/// <summary>True when the employee has an open clock entry right now.</summary>
|
||||||
|
public bool IsClockedIn { get; set; }
|
||||||
|
}
|
||||||
@@ -125,6 +125,8 @@ public class CreateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -209,4 +211,6 @@ public class UpdateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ public class WizardProgressDto
|
|||||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||||
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
||||||
|
|
||||||
public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
|
// Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
|
||||||
|
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
|
||||||
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
public interface ICustomFormulaAiService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a NCalc formula, field list, and notes from a natural-language description
|
||||||
|
/// and an optional diagram image. Returns a <see cref="GenerateFormulaFromAiResponse"/>
|
||||||
|
/// ready to pre-fill the template editor.
|
||||||
|
/// </summary>
|
||||||
|
Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
|
||||||
|
GenerateFormulaFromAiRequest request,
|
||||||
|
byte[]? imageBytes = null,
|
||||||
|
string? imageContentType = null);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
|
||||||
|
/// Safe server-side only — no user-controlled code execution.
|
||||||
|
/// </summary>
|
||||||
|
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then
|
||||||
|
/// attempts a parse-only evaluation to catch syntax errors before the formula is saved.
|
||||||
|
/// Returns the normalized formula string and a null error on success, or the original
|
||||||
|
/// formula and an error message on failure.
|
||||||
|
/// </summary>
|
||||||
|
(string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the community formula library: sharing, unsharing, importing, and browsing.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFormulaLibraryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all published library entries, with AlreadyImported populated for the given company.
|
||||||
|
/// Optionally filters by search term, output mode, or industry hint.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
|
||||||
|
int companyId,
|
||||||
|
string? search = null,
|
||||||
|
string? outputMode = null,
|
||||||
|
string? industryHint = null);
|
||||||
|
|
||||||
|
/// <summary>Full detail for the import preview modal, including field list and formula.</summary>
|
||||||
|
Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a company template to the community library.
|
||||||
|
/// If the template was previously shared and unpublished, re-publishes the existing row.
|
||||||
|
/// Updates the library entry fields from the current template state on re-share.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request);
|
||||||
|
|
||||||
|
/// <summary>Sets IsPublished = false. Existing imports are unaffected.</summary>
|
||||||
|
Task UnshareAsync(int libraryItemId, int companyId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies a library entry into the company's local CustomItemTemplate table.
|
||||||
|
/// If the company already has an import record for this entry, returns the existing template id.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> ImportAsync(int libraryItemId, int companyId, string userId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the library status for a given CustomItemTemplate — whether it is shared,
|
||||||
|
/// eligible to be shared, and where it was imported from if applicable.
|
||||||
|
/// </summary>
|
||||||
|
Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
|
||||||
|
/// when a source template's diagram is removed. Call from CompanySettingsController
|
||||||
|
/// when a diagram is deleted or replaced.
|
||||||
|
/// </summary>
|
||||||
|
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records or toggles a thumbs-up/down vote from the given company.
|
||||||
|
/// If the same vote already exists it is removed (toggle off).
|
||||||
|
/// If the opposite vote exists it is replaced.
|
||||||
|
/// Companies cannot rate their own formulas.
|
||||||
|
/// Returns the updated counts for the library entry.
|
||||||
|
/// </summary>
|
||||||
|
Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
||||||
|
int libraryItemId, int companyId, bool isPositive);
|
||||||
|
}
|
||||||
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
|
|||||||
int companyId,
|
int companyId,
|
||||||
decimal? ovenRateOverride,
|
decimal? ovenRateOverride,
|
||||||
DateTime createdAtUtc);
|
DateTime createdAtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
|
||||||
|
/// referenced by coats on the given quote, then links those coats to the new inventory records.
|
||||||
|
/// Must be called after a quote transitions to Approved status.
|
||||||
|
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
|
||||||
|
/// </summary>
|
||||||
|
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Mappings;
|
||||||
|
|
||||||
|
public class CustomItemTemplateProfile : Profile
|
||||||
|
{
|
||||||
|
public CustomItemTemplateProfile()
|
||||||
|
{
|
||||||
|
CreateMap<CustomItemTemplate, CustomItemTemplateListDto>()
|
||||||
|
.ForMember(dest => dest.FieldCount,
|
||||||
|
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||||
|
|
||||||
|
CreateMap<CustomItemTemplate, CustomItemTemplateDto>();
|
||||||
|
|
||||||
|
CreateMap<CustomItemTemplate, CustomItemTemplatePickerDto>();
|
||||||
|
|
||||||
|
CreateMap<CreateCustomItemTemplateDto, CustomItemTemplate>();
|
||||||
|
|
||||||
|
CreateMap<UpdateCustomItemTemplateDto, CustomItemTemplate>()
|
||||||
|
.ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
|
||||||
|
|
||||||
|
CreateMap<CustomItemTemplate, UpdateCustomItemTemplateDto>();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountFields(string fieldsJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||||
|
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||||
|
? doc.RootElement.GetArrayLength()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Mappings;
|
||||||
|
|
||||||
|
public class FormulaLibraryProfile : Profile
|
||||||
|
{
|
||||||
|
public FormulaLibraryProfile()
|
||||||
|
{
|
||||||
|
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||||
|
.ForMember(dest => dest.InspiredByName,
|
||||||
|
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
|
||||||
|
.ForMember(dest => dest.InspiredByCompanyName,
|
||||||
|
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
|
||||||
|
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
|
||||||
|
|
||||||
|
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
|
||||||
|
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||||
|
.ForMember(dest => dest.FieldCount,
|
||||||
|
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountFields(string fieldsJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||||
|
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||||
|
? doc.RootElement.GetArrayLength()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
|||||||
|
|
||||||
CreateMap<Invoice, InvoiceDto>()
|
CreateMap<Invoice, InvoiceDto>()
|
||||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||||
|
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||||
? (s.Customer.IsCommercial
|
? (s.Customer.IsCommercial
|
||||||
? s.Customer.CompanyName
|
? s.Customer.CompanyName
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ public class JobProfile : Profile
|
|||||||
.ForMember(dest => dest.JobItemDescription,
|
.ForMember(dest => dest.JobItemDescription,
|
||||||
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
||||||
.ForMember(dest => dest.ReworkJobNumber,
|
.ForMember(dest => dest.ReworkJobNumber,
|
||||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
|
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
|
||||||
|
.ForMember(dest => dest.ReworkPricingType,
|
||||||
|
opt => opt.MapFrom(src => src.ReworkPricingType));
|
||||||
|
|
||||||
// Job → JobDto (rework fields)
|
// Job → JobDto (rework fields)
|
||||||
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
|
|||||||
.ReverseMap()
|
.ReverseMap()
|
||||||
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||||
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
|
|||||||
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
||||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
|
||||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
||||||
@@ -190,7 +192,10 @@ public class QuoteProfile : Profile
|
|||||||
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
||||||
|
|
||||||
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
||||||
|
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
|
||||||
|
// is unreliable for ICollection<T> → List<T2> with different element types.
|
||||||
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
||||||
|
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||||
|
|
||||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
Complexity = seed.Complexity,
|
Complexity = seed.Complexity,
|
||||||
AiTags = seed.AiTags,
|
AiTags = seed.AiTags,
|
||||||
AiPredictionId = seed.AiPredictionId,
|
AiPredictionId = seed.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = seed.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = seed.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
};
|
};
|
||||||
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public string? Complexity { get; init; }
|
public string? Complexity { get; init; }
|
||||||
public string? AiTags { get; init; }
|
public string? AiTags { get; init; }
|
||||||
public int? AiPredictionId { get; init; }
|
public int? AiPredictionId { get; init; }
|
||||||
|
public bool IsCustomFormulaItem { get; init; }
|
||||||
|
public int? CustomItemTemplateId { get; init; }
|
||||||
|
public string? FormulaFieldValuesJson { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||||
|
|||||||
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
|
|||||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||||
|
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,6 +611,15 @@ public class PdfService : IPdfService
|
|||||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||||
|
{
|
||||||
|
column.Item().Row(row =>
|
||||||
|
{
|
||||||
|
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||||
|
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when a coat requires ordering custom powder that is not in inventory.
|
||||||
|
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
|
||||||
|
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
|
||||||
|
!coat.InventoryItemId.HasValue &&
|
||||||
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
||||||
/// path based on item type:
|
/// path based on item type:
|
||||||
@@ -288,6 +298,26 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
|
||||||
|
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
|
||||||
|
// exactly like every other item type that uses ManualUnitPrice.
|
||||||
|
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
|
||||||
|
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
|
||||||
|
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
||||||
|
{
|
||||||
|
var formulaUnitPrice = item.ManualUnitPrice.Value;
|
||||||
|
var formulaTotal = formulaUnitPrice * item.Quantity;
|
||||||
|
return new QuoteItemPricingResult
|
||||||
|
{
|
||||||
|
MaterialCost = 0,
|
||||||
|
LaborCost = 0,
|
||||||
|
EquipmentCost = 0,
|
||||||
|
ItemSubtotal = formulaTotal,
|
||||||
|
UnitPrice = formulaUnitPrice,
|
||||||
|
TotalPrice = formulaTotal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
||||||
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
||||||
{
|
{
|
||||||
@@ -312,6 +342,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
{
|
{
|
||||||
for (int i = 0; i < item.Coats.Count; i++)
|
for (int i = 0; i < item.Coats.Count; i++)
|
||||||
{
|
{
|
||||||
|
// Custom powder material moves to the "Custom Powder Order" line item
|
||||||
|
if (IsCustomPowderCoat(item.Coats[i])) continue;
|
||||||
var coatResult = await CalculateCoatPriceAsync(
|
var coatResult = await CalculateCoatPriceAsync(
|
||||||
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
||||||
coatMaterialCost += coatResult.CoatMaterialCost;
|
coatMaterialCost += coatResult.CoatMaterialCost;
|
||||||
@@ -413,7 +445,9 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
for (int ci = 0; ci < item.Coats.Count; ci++)
|
for (int ci = 0; ci < item.Coats.Count; ci++)
|
||||||
{
|
{
|
||||||
var coat = item.Coats[ci];
|
var coat = item.Coats[ci];
|
||||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
// Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
|
||||||
|
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
|
||||||
|
&& !IsCustomPowderCoat(coat))
|
||||||
{
|
{
|
||||||
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
||||||
totalMaterialCost += coatResult.CoatMaterialCost;
|
totalMaterialCost += coatResult.CoatMaterialCost;
|
||||||
@@ -431,7 +465,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
{
|
{
|
||||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
// Custom powder material moves to the "Custom Powder Order" line item; keep the labor
|
||||||
|
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
|
||||||
coatLaborCost = firstCoatResult.CoatLaborCost;
|
coatLaborCost = firstCoatResult.CoatLaborCost;
|
||||||
totalLaborCost = coatLaborCost;
|
totalLaborCost = coatLaborCost;
|
||||||
}
|
}
|
||||||
@@ -628,6 +663,49 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
// 4. TOTAL ITEMS SUBTOTAL
|
// 4. TOTAL ITEMS SUBTOTAL
|
||||||
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
||||||
|
|
||||||
|
// Powder-to-order costs are excluded from individual item prices and collected in a
|
||||||
|
// "Custom Powder Order" line item added at save time. For live pricing previews (before
|
||||||
|
// save), add them back here so the displayed total stays correct throughout the session.
|
||||||
|
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
|
||||||
|
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
|
||||||
|
bool hasCustomPowderOrderItem = items.Any(i =>
|
||||||
|
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
|
||||||
|
decimal customPowderOrderAmount = 0m;
|
||||||
|
var customPowderOrderColors = new List<string>();
|
||||||
|
if (!hasCustomPowderOrderItem)
|
||||||
|
{
|
||||||
|
foreach (var item in items.Where(i => i.Coats != null))
|
||||||
|
{
|
||||||
|
foreach (var c in item.Coats!)
|
||||||
|
{
|
||||||
|
if (!c.InventoryItemId.HasValue &&
|
||||||
|
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
|
||||||
|
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
|
||||||
|
{
|
||||||
|
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(c.ColorName))
|
||||||
|
customPowderOrderColors.Add(c.ColorName);
|
||||||
|
}
|
||||||
|
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
|
||||||
|
{
|
||||||
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
||||||
|
if (invItem?.IsIncoming == true)
|
||||||
|
{
|
||||||
|
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
|
||||||
|
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
customPowderOrderColors.Add(colorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (customPowderOrderAmount > 0)
|
||||||
|
{
|
||||||
|
itemsSubtotal += customPowderOrderAmount;
|
||||||
|
totalMaterialCosts += customPowderOrderAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
||||||
// AI items already have oven cost baked into their AI-estimated price, so we only
|
// AI items already have oven cost baked into their AI-estimated price, so we only
|
||||||
// charge the proportion of the oven that's attributable to non-AI items.
|
// charge the proportion of the oven that's attributable to non-AI items.
|
||||||
@@ -806,7 +884,11 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
||||||
LaborCosts = Math.Round(totalLaborCosts, 2),
|
LaborCosts = Math.Round(totalLaborCosts, 2),
|
||||||
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
||||||
ItemResults = itemResults
|
ItemResults = itemResults,
|
||||||
|
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
|
||||||
|
CustomPowderOrderColors = customPowderOrderColors
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||||
|
|
||||||
|
var dtoList = itemDtos.ToList();
|
||||||
var items = new List<QuoteItem>();
|
var items = new List<QuoteItem>();
|
||||||
foreach (var itemDto in itemDtos)
|
foreach (var itemDto in dtoList)
|
||||||
{
|
{
|
||||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||||
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
items.Add(item);
|
items.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option B: auto-create the Custom Powder Order item only on first save.
|
||||||
|
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
|
||||||
|
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
|
||||||
|
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
|
||||||
|
if (!hasExistingCustomPowderOrder)
|
||||||
|
{
|
||||||
|
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
|
||||||
|
if (customPowderItem != null)
|
||||||
|
items.Add(customPowderItem);
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +142,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
|
||||||
|
{
|
||||||
|
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||||
|
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||||
|
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (itemDto.CatalogItemId.HasValue)
|
if (itemDto.CatalogItemId.HasValue)
|
||||||
{
|
{
|
||||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||||
@@ -161,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||||
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
|
||||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
|
||||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
/// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
|
||||||
|
/// quote (deduplication). No inventory is created during quote save.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
@@ -175,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
{
|
{
|
||||||
var coatDto = itemDto.Coats[coatIndex];
|
var coatDto = itemDto.Coats[coatIndex];
|
||||||
|
|
||||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
// Incoming-inventory creation is intentionally deferred to quote approval.
|
||||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
// PowderCatalogItemId is persisted on the coat entity for later use.
|
||||||
|
|
||||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||||
@@ -243,6 +264,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
IsAiItem = itemDto.IsAiItem,
|
IsAiItem = itemDto.IsAiItem,
|
||||||
AiTags = itemDto.AiTags,
|
AiTags = itemDto.AiTags,
|
||||||
AiPredictionId = itemDto.AiPredictionId,
|
AiPredictionId = itemDto.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = itemDto.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
};
|
};
|
||||||
@@ -256,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
CoatName = coatDto.CoatName,
|
CoatName = coatDto.CoatName,
|
||||||
Sequence = coatDto.Sequence,
|
Sequence = coatDto.Sequence,
|
||||||
InventoryItemId = coatDto.InventoryItemId,
|
InventoryItemId = coatDto.InventoryItemId,
|
||||||
|
PowderCatalogItemId = coatDto.CatalogItemId,
|
||||||
ColorName = coatDto.ColorName,
|
ColorName = coatDto.ColorName,
|
||||||
VendorId = coatDto.VendorId,
|
VendorId = coatDto.VendorId,
|
||||||
ColorCode = coatDto.ColorCode,
|
ColorCode = coatDto.ColorCode,
|
||||||
@@ -305,31 +330,33 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
|
||||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
/// Called at quote-approval time (not during quote save) so inventory records only appear
|
||||||
|
/// when a job is actually going to be created. The caller groups coats by
|
||||||
|
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
|
||||||
|
/// duplicate records when the same powder appears on multiple items in the same quote.
|
||||||
///
|
///
|
||||||
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
|
||||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
/// so the item always lands in the right bucket regardless of how many IsCoating categories
|
||||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
|
||||||
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
|
||||||
///
|
///
|
||||||
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
|
||||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
/// from the manufacturer product page. Best-effort — item is still created from catalog data
|
||||||
/// if it fails, the item is still created with whatever data the catalog has.
|
/// if the AI call fails.
|
||||||
///
|
|
||||||
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
|
|
||||||
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
|
|
||||||
/// inventory unit cost rather than the now-stale manual price from the quote form.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
||||||
if (catalogItem == null) return null;
|
if (catalogItem == null) return null;
|
||||||
|
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||||
var coatingCategory = categories
|
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
|
||||||
|
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
|
||||||
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
|
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
.Where(c => c.IsActive && c.IsCoating)
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
@@ -437,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
|
||||||
coatDto.PowderCostPerLb = null;
|
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
|
||||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
item.Id, item.Name, catalogItemId);
|
||||||
item.Id, item.Name, coatDto.CatalogItemId);
|
|
||||||
|
|
||||||
return item.Id;
|
return item.Id;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||||
coatDto.CatalogItemId);
|
catalogItemId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
|
||||||
|
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
|
||||||
|
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
|
||||||
|
/// on the first save only — Option B means the user owns the price after creation.
|
||||||
|
///
|
||||||
|
/// Coat types that qualify:
|
||||||
|
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered)
|
||||||
|
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
|
||||||
|
/// pre-filled from catalog unit price (inventory creation deferred to approval)
|
||||||
|
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
|
||||||
|
/// </summary>
|
||||||
|
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
||||||
|
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
|
||||||
|
{
|
||||||
|
var colorNames = new List<string>();
|
||||||
|
decimal totalCost = 0m;
|
||||||
|
|
||||||
|
foreach (var itemDto in itemDtos)
|
||||||
|
{
|
||||||
|
if (itemDto.Coats == null) continue;
|
||||||
|
foreach (var coat in itemDto.Coats)
|
||||||
|
{
|
||||||
|
if (!coat.InventoryItemId.HasValue &&
|
||||||
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||||
|
{
|
||||||
|
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
|
||||||
|
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
|
||||||
|
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||||
|
colorNames.Add(coat.ColorName);
|
||||||
|
}
|
||||||
|
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
|
{
|
||||||
|
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
|
||||||
|
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
|
||||||
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||||
|
if (invItem?.IsIncoming == true)
|
||||||
|
{
|
||||||
|
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
||||||
|
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
colorNames.Add(colorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCost <= 0) return null;
|
||||||
|
|
||||||
|
var uniqueColors = colorNames
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
var description = uniqueColors.Any()
|
||||||
|
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
||||||
|
: "Custom Powder Order";
|
||||||
|
|
||||||
|
return new QuoteItem
|
||||||
|
{
|
||||||
|
QuoteId = quoteId,
|
||||||
|
Description = description,
|
||||||
|
Quantity = 1,
|
||||||
|
IsGenericItem = true,
|
||||||
|
ManualUnitPrice = totalCost,
|
||||||
|
UnitPrice = totalCost,
|
||||||
|
TotalPrice = totalCost,
|
||||||
|
ItemMaterialCost = totalCost,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = createdAtUtc,
|
||||||
|
Coats = [],
|
||||||
|
PrepServices = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
|
||||||
|
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
|
||||||
|
/// new (or existing) inventory record.
|
||||||
|
///
|
||||||
|
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
|
||||||
|
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
|
||||||
|
/// only reflects powders the shop is actually going to process.
|
||||||
|
///
|
||||||
|
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
|
||||||
|
/// same InventoryItemId — no duplicate records are created.
|
||||||
|
///
|
||||||
|
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
|
||||||
|
/// on an already-approved quote (e.g. retry after a transient error) is safe.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
|
||||||
|
{
|
||||||
|
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
|
||||||
|
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
|
||||||
|
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
|
||||||
|
false,
|
||||||
|
qi => qi.Coats);
|
||||||
|
|
||||||
|
var pendingCoats = quoteItems
|
||||||
|
.SelectMany(qi => qi.Coats)
|
||||||
|
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (pendingCoats.Count == 0) return;
|
||||||
|
|
||||||
|
// Group by catalog item ID so each unique powder generates exactly one inventory record.
|
||||||
|
var groups = pendingCoats
|
||||||
|
.GroupBy(c => c.PowderCatalogItemId!.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
|
||||||
|
if (newInventoryId == null) continue;
|
||||||
|
|
||||||
|
// Link every coat in this group to the single newly-created inventory record.
|
||||||
|
foreach (var coat in group)
|
||||||
|
{
|
||||||
|
coat.InventoryItemId = newInventoryId;
|
||||||
|
coat.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
||||||
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
|
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
|
||||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
/// and the Company Settings live preview (so the UI always shows the same rate
|
||||||
|
/// the AI will use — single formula path, no client-side duplication).
|
||||||
///
|
///
|
||||||
/// Formula:
|
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
|
||||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
/// determines throughput and CFM draw. CFM is not used in the rate formula.
|
||||||
///
|
///
|
||||||
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
|
/// Sources:
|
||||||
/// All multipliers are relative to that baseline.
|
/// Pressure pot rates — averaged from two industry standard abrasive blast
|
||||||
|
/// cleaning reference tables.
|
||||||
|
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
|
||||||
|
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ShopCapabilityCalculator
|
public static class ShopCapabilityCalculator
|
||||||
{
|
{
|
||||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
// ── Public entry points ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr.
|
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
|
||||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
|
||||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (costs.CompressorCfm <= 0)
|
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(costs.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
|
|
||||||
var setup = SetupMultiplier(costs.BlastSetupType);
|
|
||||||
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setup * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
/// Returns the effective blast rate in sqft/hr for a named blast setup.
|
||||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// otherwise derives from the setup's equipment specs.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||||
{
|
{
|
||||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (setup.CompressorCfm <= 0)
|
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(setup.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
|
|
||||||
var setupMult = SetupMultiplier(setup.SetupType);
|
|
||||||
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective coating application rate in sqft/hr.
|
/// Returns the effective coating application rate in sqft/hr.
|
||||||
/// If override is set, returns it directly.
|
/// Override bypasses the formula when set.
|
||||||
/// Otherwise derives a sensible default from gun type.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.CoatingRateSqFtPerHourOverride.Value;
|
return costs.CoatingRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
|
|
||||||
// Without more equipment data (voltage, gun model) we use a single reasonable default.
|
|
||||||
return costs.CoatingGunType switch
|
return costs.CoatingGunType switch
|
||||||
{
|
{
|
||||||
CoatingGunType.Corona => 40m,
|
CoatingGunType.Corona => 40m,
|
||||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
CoatingGunType.Tribo => 35m,
|
||||||
CoatingGunType.Both => 40m,
|
CoatingGunType.Both => 40m,
|
||||||
_ => 40m
|
_ => 40m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns default equipment field values for a given capability tier.
|
/// Returns default equipment field values for a given capability tier, applied
|
||||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
/// during Setup Wizard tier selection so new shops get reasonable starting values.
|
||||||
/// starting values even if they never visit the Quoting Calibration tab.
|
/// CFM defaults reflect typical compressor sizes for each tier; they appear in the
|
||||||
|
/// UI for reference but are not used in the rate formula.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||||
{
|
{
|
||||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
|
||||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Core formula (single path for all callers) ─────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
|
||||||
/// Calibrated so that real-world examples produce expected results:
|
/// setup type routes to the appropriate reference table; substrate adjusts for
|
||||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
|
||||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
/// not an independent variable in throughput.
|
||||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
|
||||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
|
||||||
{
|
{
|
||||||
< 10 => 5m,
|
var baseRate = setupType switch
|
||||||
< 20 => 9m,
|
{
|
||||||
< 40 => 15m,
|
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
|
||||||
< 80 => 25m,
|
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
|
||||||
< 120 => 35m,
|
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
|
||||||
_ => 45m
|
BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
|
||||||
|
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
|
||||||
|
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
|
||||||
|
_ => 0m
|
||||||
};
|
};
|
||||||
|
|
||||||
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
|
return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
|
||||||
|
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
|
||||||
|
/// #1 (1/16"): 20-35 sqft/hr avg → 20
|
||||||
|
/// #2 (1/8"): 40-60 sqft/hr avg → 40
|
||||||
|
/// #3 (3/16"): 60-85 sqft/hr avg → 75
|
||||||
|
/// #4 (1/4"): 90-110 sqft/hr avg → 115
|
||||||
|
/// #5 (5/16"): 130-160 sqft/hr avg → 175
|
||||||
|
/// #6 (3/8"): 180-230 sqft/hr avg → 245
|
||||||
|
/// #7 (7/16"): 240-300 sqft/hr avg → 325
|
||||||
|
/// #8 (1/2"): 320-400 sqft/hr avg → 430
|
||||||
|
/// </summary>
|
||||||
|
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
|
||||||
{
|
{
|
||||||
2 => 0.35m,
|
1 => 20m,
|
||||||
3 => 0.55m,
|
2 => 40m,
|
||||||
4 => 0.75m,
|
3 => 75m,
|
||||||
5 => 1.00m,
|
4 => 115m,
|
||||||
6 => 1.30m,
|
5 => 175m,
|
||||||
7 => 1.65m,
|
6 => 245m,
|
||||||
8 => 2.00m,
|
7 => 325m,
|
||||||
_ => 1.00m
|
8 => 430m,
|
||||||
|
_ => 100m
|
||||||
};
|
};
|
||||||
|
|
||||||
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
|
/// <summary>
|
||||||
|
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
|
||||||
|
/// Source: industry reference table for siphon cabinet production rates.
|
||||||
|
/// #1 (1/16"): 10-25 sqft/hr → 18
|
||||||
|
/// #2 (1/8"): 25-50 sqft/hr → 38
|
||||||
|
/// #3 (3/16"): 50-100 sqft/hr → 75
|
||||||
|
/// #4 (1/4"): 100-150 sqft/hr → 125
|
||||||
|
/// #5 (5/16"): 150-225 sqft/hr → 188
|
||||||
|
/// #6 (3/8"): 225-300 sqft/hr → 263
|
||||||
|
/// #7 (7/16"): 300-375 sqft/hr → 338
|
||||||
|
/// #8 (1/2"): 375-450 sqft/hr → 413
|
||||||
|
/// </summary>
|
||||||
|
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
|
||||||
{
|
{
|
||||||
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
|
1 => 18m,
|
||||||
BlastSetupType.SiphonPot => 0.70m,
|
2 => 38m,
|
||||||
BlastSetupType.PressurePot => 1.00m, // baseline
|
3 => 75m,
|
||||||
BlastSetupType.WetBlasting => 0.60m,
|
4 => 125m,
|
||||||
_ => 1.00m
|
5 => 188m,
|
||||||
|
6 => 263m,
|
||||||
|
7 => 338m,
|
||||||
|
8 => 413m,
|
||||||
|
_ => 80m
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
|
||||||
|
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
|
||||||
|
/// </summary>
|
||||||
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
||||||
{
|
{
|
||||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
BlastSubstrateType.PowderCoat => 1.25m,
|
||||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
BlastSubstrateType.Paint => 1.00m,
|
||||||
BlastSubstrateType.Mixed => 0.90m,
|
BlastSubstrateType.Mixed => 0.90m,
|
||||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
BlastSubstrateType.RustAndScale => 0.70m,
|
||||||
_ => 0.90m
|
_ => 0.90m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
|
|||||||
// Passkey enrollment prompt
|
// Passkey enrollment prompt
|
||||||
public bool PasskeyPromptDismissed { get; set; } = false;
|
public bool PasskeyPromptDismissed { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
|
||||||
|
public string? KioskPin { get; set; }
|
||||||
|
|
||||||
// Ban
|
// Ban
|
||||||
public bool IsBanned { get; set; } = false;
|
public bool IsBanned { get; set; } = false;
|
||||||
public DateTime? BannedAt { get; set; }
|
public DateTime? BannedAt { get; set; }
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ public class Company : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? KioskActivationToken { get; set; }
|
public string? KioskActivationToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
|
||||||
|
public bool TimeclockEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
|
|
||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A per-company reusable pricing formula template. Users define named input fields and an
|
||||||
|
/// NCalc expression that produces either a fixed dollar amount (FixedRate) or a surface area
|
||||||
|
/// in square feet (SurfaceAreaSqFt) that feeds the standard coatings pricing path.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomItemTemplate : BaseEntity
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — controls which pricing path is used after evaluation.</summary>
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
|
||||||
|
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Default rate value populated into the quote wizard; user can override per quote.</summary>
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Display label for the rate field, e.g. "$/sq in" or "$/lb".</summary>
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional reference diagram (shop drawing, sketch) stored in blob storage.
|
||||||
|
/// Shown in the template editor and quote wizard so users know which measurements to take.
|
||||||
|
/// Path format: {companyId}/{templateId}/diagram.{ext}
|
||||||
|
/// </summary>
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
|
||||||
|
// ── Community library tracking ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when this template was imported from the community library.
|
||||||
|
/// Null for originally created templates.
|
||||||
|
/// </summary>
|
||||||
|
public int? SourceFormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once the user edits an imported template. Only modified imports (and original
|
||||||
|
/// creations) are eligible to be shared back to the community library.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsModifiedFromSource { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Facility-level clock-in/clock-out record for an employee.
|
||||||
|
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
|
||||||
|
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
|
||||||
|
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
|
||||||
|
/// </summary>
|
||||||
|
public class EmployeeClockEntry : BaseEntity
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null means the employee is currently clocked in.</summary>
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
|
||||||
|
public decimal? HoursWorked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this segment is regular work time, a break, or a lunch period.
|
||||||
|
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
|
||||||
|
/// </summary>
|
||||||
|
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public virtual ApplicationUser User { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
|
|||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
// Maintenance Information
|
// Maintenance Information
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a company imported a specific FormulaLibraryItem into their local template library.
|
||||||
|
/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
|
||||||
|
/// same item overwrites the existing row rather than creating a duplicate.
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryImport : BaseEntity
|
||||||
|
{
|
||||||
|
public int FormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||||
|
|
||||||
|
public string ImportedByUserId { get; set; } = string.Empty;
|
||||||
|
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>The CustomItemTemplate row created in this company's local library on import.</summary>
|
||||||
|
public int ResultingCustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-level community library entry for a shared custom formula template.
|
||||||
|
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
|
||||||
|
/// Shared voluntarily by the originating company; imported as independent copies by others.
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// ── Formula content (copied from CustomItemTemplate at share time) ─────
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.</summary>
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
|
||||||
|
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blob path referencing the source template's diagram image.
|
||||||
|
/// Nulled out (here and on all imports) if the source template's diagram is removed.
|
||||||
|
/// </summary>
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
|
||||||
|
// ── Attribution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Comma-separated community tags, e.g. "HVAC,Sheet Metal".</summary>
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".</summary>
|
||||||
|
public string? IndustryHint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Id of the CustomItemTemplate this was shared from.</summary>
|
||||||
|
public int SourceCustomItemTemplateId { get; set; }
|
||||||
|
|
||||||
|
public int SourceCompanyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Denormalized company name so it renders without a join when the company is gone.</summary>
|
||||||
|
public string SourceCompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When non-null, this entry was derived from an imported formula that was subsequently
|
||||||
|
/// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
|
||||||
|
/// </summary>
|
||||||
|
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem? InspiredBy { get; set; }
|
||||||
|
|
||||||
|
public string SharedByUserId { get; set; } = string.Empty;
|
||||||
|
public DateTime SharedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>False when the creator has removed it from the community library.</summary>
|
||||||
|
public bool IsPublished { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Running count of how many companies have imported this entry.</summary>
|
||||||
|
public int ImportCount { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One thumbs-up or thumbs-down vote per company per library formula.
|
||||||
|
/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
|
||||||
|
/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryRating
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int FormulaLibraryItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The company casting the vote.</summary>
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True = thumbs up, false = thumbs down.</summary>
|
||||||
|
public bool IsPositive { get; set; }
|
||||||
|
|
||||||
|
public DateTime RatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
|
|||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
|
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
|||||||
|
|
||||||
// Additional Information
|
// Additional Information
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
|
|||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual Job Job { get; set; } = null!;
|
public virtual Job Job { get; set; } = null!;
|
||||||
public virtual CatalogItem? CatalogItem { get; set; }
|
public virtual CatalogItem? CatalogItem { get; set; }
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Conversion tracking
|
// Conversion tracking
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
|
|||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual Quote Quote { get; set; } = null!;
|
public virtual Quote Quote { get; set; } = null!;
|
||||||
public virtual CatalogItem? CatalogItem { get; set; }
|
public virtual CatalogItem? CatalogItem { get; set; }
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
|
|||||||
|
|
||||||
// Powder selection (same pattern as current QuoteItem)
|
// Powder selection (same pattern as current QuoteItem)
|
||||||
public int? InventoryItemId { get; set; } // In-stock powder
|
public int? InventoryItemId { get; set; } // In-stock powder
|
||||||
|
/// <summary>
|
||||||
|
/// Platform powder catalog item that this coat was sourced from.
|
||||||
|
/// Persisted so that at quote-approval time the system can create exactly one
|
||||||
|
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
|
||||||
|
/// than creating during quote-save when the job may never be approved.
|
||||||
|
/// </summary>
|
||||||
|
public int? PowderCatalogItemId { get; set; }
|
||||||
public string? ColorName { get; set; } // Color name
|
public string? ColorName { get; set; } // Color name
|
||||||
public int? VendorId { get; set; } // Vendor for custom powder
|
public int? VendorId { get; set; } // Vendor for custom powder
|
||||||
public string? ColorCode { get; set; } // RAL code, etc.
|
public string? ColorCode { get; set; } // RAL code, etc.
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
|
|||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Pricing attribution for the linked rework job (null on pre-existing records)
|
||||||
|
public ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
// ── Resolution ────────────────────────────────────────────────────────────
|
// ── Resolution ────────────────────────────────────────────────────────────
|
||||||
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
||||||
public ReworkResolution? Resolution { get; set; }
|
public ReworkResolution? Resolution { get; set; }
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
|
|||||||
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
||||||
public bool AllowSms { get; set; } = false;
|
public bool AllowSms { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
|
||||||
|
public bool AllowCustomFormulas { get; set; } = false;
|
||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
|
|||||||
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
||||||
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
||||||
public virtual Account? DefaultExpenseAccount { get; set; }
|
public virtual Account? DefaultExpenseAccount { get; set; }
|
||||||
|
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InventoryTransaction : BaseEntity
|
public class InventoryTransaction : BaseEntity
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an activated shop-floor kiosk tablet for the timeclock.
|
||||||
|
/// One row per device; multiple rows per company are supported so shops can have
|
||||||
|
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
|
||||||
|
/// device-specific cookie and validated on every kiosk request.
|
||||||
|
/// </summary>
|
||||||
|
public class TimeclockKioskDevice : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
|
||||||
|
public string? DeviceName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when a manager activated this device.</summary>
|
||||||
|
public DateTime ActivatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
}
|
||||||
@@ -144,6 +144,14 @@ public enum ReworkResolution
|
|||||||
NoActionRequired = 4
|
NoActionRequired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
|
||||||
|
public enum ReworkPricingType
|
||||||
|
{
|
||||||
|
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
|
||||||
|
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
|
||||||
|
CustomerFull = 2 // Customer caused it; full original pricing applies
|
||||||
|
}
|
||||||
|
|
||||||
public enum BugReportStatus
|
public enum BugReportStatus
|
||||||
{
|
{
|
||||||
New = 0,
|
New = 0,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
|
||||||
|
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
|
||||||
|
/// </summary>
|
||||||
|
public enum ClockEntryType
|
||||||
|
{
|
||||||
|
/// <summary>Normal productive work time (default).</summary>
|
||||||
|
Work = 0,
|
||||||
|
|
||||||
|
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
|
||||||
|
Break = 1,
|
||||||
|
|
||||||
|
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
|
||||||
|
Lunch = 2
|
||||||
|
}
|
||||||
@@ -155,6 +155,18 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
|||||||
// Customer Intake Kiosk
|
// Customer Intake Kiosk
|
||||||
IRepository<KioskSession> KioskSessions { get; }
|
IRepository<KioskSession> KioskSessions { get; }
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
|
||||||
|
|
||||||
|
// Formula Community Library
|
||||||
|
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
|
||||||
|
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
|
||||||
|
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
|
||||||
|
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync();
|
Task<int> SaveChangesAsync();
|
||||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||||
|
|
||||||
|
|||||||
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// were never completed and rolled past their scheduled day.
|
/// were never completed and rolled past their scheduled day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||||
|
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetReworkJobCountAsync(int originalJobId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,6 +289,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
|
||||||
|
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
|
||||||
|
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
|
||||||
|
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
|
||||||
|
|
||||||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<BugReport> BugReports { get; set; }
|
public DbSet<BugReport> BugReports { get; set; }
|
||||||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||||||
@@ -374,6 +383,17 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<KioskSession> KioskSessions { get; set; }
|
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
|
||||||
|
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
|
||||||
|
|
||||||
|
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
|
||||||
|
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||||
/// No global query filter — SuperAdmin controllers query this directly.
|
/// No global query filter — SuperAdmin controllers query this directly.
|
||||||
@@ -767,6 +787,32 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(k => k.LinkedJobId)
|
.HasForeignKey(k => k.LinkedJobId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Custom Formula Templates — tenant-filtered + soft delete
|
||||||
|
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
|
||||||
|
// Employee Timeclock — tenant-filtered + soft delete
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
|
||||||
|
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>()
|
||||||
|
.HasOne(c => c.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
// Composite index for "who's clocked in today" and date-range attendance reports
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>()
|
||||||
|
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
|
||||||
|
|
||||||
|
// Timeclock kiosk devices — one row per activated tablet per company
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
|
||||||
|
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||||
|
.HasIndex(d => d.Token).IsUnique();
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||||
|
.HasIndex(d => d.CompanyId);
|
||||||
|
|
||||||
// Account self-referencing hierarchy
|
// Account self-referencing hierarchy
|
||||||
modelBuilder.Entity<Account>()
|
modelBuilder.Entity<Account>()
|
||||||
.HasOne(a => a.ParentAccount)
|
.HasOne(a => a.ParentAccount)
|
||||||
@@ -809,6 +855,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
|
||||||
|
modelBuilder.Entity<Vendor>()
|
||||||
|
.HasMany(v => v.Categories)
|
||||||
|
.WithMany(c => c.Vendors)
|
||||||
|
.UsingEntity<Dictionary<string, object>>(
|
||||||
|
"VendorInventoryCategories",
|
||||||
|
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
|
||||||
|
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
|
||||||
|
|
||||||
// Bill → APAccount (no cascade to avoid cycles)
|
// Bill → APAccount (no cascade to avoid cycles)
|
||||||
modelBuilder.Entity<Bill>()
|
modelBuilder.Entity<Bill>()
|
||||||
.HasOne(b => b.APAccount)
|
.HasOne(b => b.APAccount)
|
||||||
@@ -2028,6 +2083,61 @@ modelBuilder.Entity<Job>()
|
|||||||
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
||||||
|
|
||||||
|
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
|
||||||
|
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasOne(f => f.InspiredBy)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasIndex(f => f.SourceCompanyId)
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasIndex(f => f.IsPublished)
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||||
|
|
||||||
|
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasOne(i => i.FormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FormulaLibraryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasOne(i => i.ResultingCustomItemTemplate)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||||
|
|
||||||
|
// FormulaLibraryRating — platform-level; one vote per company per formula
|
||||||
|
modelBuilder.Entity<FormulaLibraryRating>()
|
||||||
|
.HasOne(r => r.FormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FormulaLibraryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryRating>()
|
||||||
|
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||||
|
|
||||||
|
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
|
||||||
|
modelBuilder.Entity<CustomItemTemplate>()
|
||||||
|
.HasOne(t => t.SourceFormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Generated
+10642
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReworkPricingType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ReworkPricingType",
|
||||||
|
table: "ReworkRecords",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReworkPricingType",
|
||||||
|
table: "ReworkRecords");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVendorCategories : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VendorInventoryCategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
VendorId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
|
||||||
|
column: x => x.InventoryCategoryLookupId,
|
||||||
|
principalTable: "InventoryCategoryLookups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VendorInventoryCategories_Vendors_VendorId",
|
||||||
|
column: x => x.VendorId,
|
||||||
|
principalTable: "Vendors",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VendorInventoryCategories_VendorId",
|
||||||
|
table: "VendorInventoryCategories",
|
||||||
|
column: "VendorId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VendorInventoryCategories");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10780
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomItemTemplates : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "QuoteItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "QuoteItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "QuoteItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomItemTemplates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
DisplayOrder = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomItemTemplates", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems",
|
||||||
|
column: "CustomItemTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JobItems_CustomItemTemplateId",
|
||||||
|
table: "JobItems",
|
||||||
|
column: "CustomItemTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "JobItems",
|
||||||
|
column: "CustomItemTemplateId",
|
||||||
|
principalTable: "CustomItemTemplates",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems",
|
||||||
|
column: "CustomItemTemplateId",
|
||||||
|
principalTable: "CustomItemTemplates",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_JobItems_CustomItemTemplateId",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10672
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakeMaintenanceIntervalNullable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
type: "int",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAllowCustomFormulas : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowCustomFormulas",
|
||||||
|
table: "SubscriptionPlanConfigs",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowCustomFormulas",
|
||||||
|
table: "SubscriptionPlanConfigs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10854
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEmployeeTimeclock : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskPin",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EmployeeClockEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
ClockInTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
ClockOutTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
HoursWorked = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EmployeeClockEntries", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EmployeeClockEntries_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmployeeClockEntries_CompanyId_ClockInTime",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
columns: new[] { "CompanyId", "ClockInTime" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmployeeClockEntries_UserId",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EmployeeClockEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskPin",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10857
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTimeclockKioskToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTimeclockSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||||
|
table: "Companies",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "TimeclockAutoClockOutHours",
|
||||||
|
table: "Companies",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TimeclockEnabled",
|
||||||
|
table: "Companies",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TimeclockKioskDevices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Token = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TimeclockKioskDevices", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TimeclockKioskDevices_CompanyId",
|
||||||
|
table: "TimeclockKioskDevices",
|
||||||
|
column: "CompanyId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TimeclockKioskDevices_Token",
|
||||||
|
table: "TimeclockKioskDevices",
|
||||||
|
column: "Token",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TimeclockKioskDevices");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockAutoClockOutHours",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockEnabled",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10921
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClockEntryType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "EntryType",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EntryType",
|
||||||
|
table: "EmployeeClockEntries");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10924
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPowderCatalogItemIdToCoat : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "QuoteItemCoats",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "QuoteItemCoats");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFormulaLibrary : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsModifiedFromSource",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FormulaLibraryItems",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Tags = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IndustryHint = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
SourceCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SourceCompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SourceCompanyName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
InspiredByFormulaLibraryItemId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
SharedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
SharedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
IsPublished = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
ImportCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FormulaLibraryItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryItems_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
|
||||||
|
column: x => x.InspiredByFormulaLibraryItemId,
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FormulaLibraryImports",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ImportedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
ImportedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
ResultingCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FormulaLibraryImports", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryImports_CustomItemTemplates_ResultingCustomItemTemplateId",
|
||||||
|
column: x => x.ResultingCustomItemTemplateId,
|
||||||
|
principalTable: "CustomItemTemplates",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryImports_FormulaLibraryItems_FormulaLibraryItemId",
|
||||||
|
column: x => x.FormulaLibraryItemId,
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
column: "SourceFormulaLibraryItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryImports_Company_Item",
|
||||||
|
table: "FormulaLibraryImports",
|
||||||
|
columns: new[] { "CompanyId", "FormulaLibraryItemId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryImports_FormulaLibraryItemId",
|
||||||
|
table: "FormulaLibraryImports",
|
||||||
|
column: "FormulaLibraryItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryImports_ResultingCustomItemTemplateId",
|
||||||
|
table: "FormulaLibraryImports",
|
||||||
|
column: "ResultingCustomItemTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
|
||||||
|
table: "FormulaLibraryItems",
|
||||||
|
column: "InspiredByFormulaLibraryItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryItems_IsPublished",
|
||||||
|
table: "FormulaLibraryItems",
|
||||||
|
column: "IsPublished");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryItems_SourceCompanyId",
|
||||||
|
table: "FormulaLibraryItems",
|
||||||
|
column: "SourceCompanyId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
column: "SourceFormulaLibraryItemId",
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FormulaLibraryImports");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FormulaLibraryItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsModifiedFromSource",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11159
File diff suppressed because it is too large
Load Diff
+92
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFormulaLibraryRatings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FormulaLibraryRatings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsPositive = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
RatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FormulaLibraryRatings", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryRatings_FormulaLibraryItems_FormulaLibraryItemId",
|
||||||
|
column: x => x.FormulaLibraryItemId,
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryRatings_Item_Company",
|
||||||
|
table: "FormulaLibraryRatings",
|
||||||
|
columns: new[] { "FormulaLibraryItemId", "CompanyId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FormulaLibraryRatings");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
src/PowderCoating.Infrastructure/Migrations/20260608182208_AddProjectNameToQuotesAndJobs.Designer.cs
Generated
+11165
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddProjectNameToQuotesAndJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11168
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInvoiceProjectName : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Invoices",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Invoices");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsBanned")
|
b.Property<bool>("IsBanned")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("KioskPin")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<decimal?>("LaborCostPerHour")
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -1923,6 +1926,15 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("TimeZone")
|
b.Property<string>("TimeZone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("TimeclockAllowMultiplePunchesPerDay")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int?>("TimeclockAutoClockOutHours")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("TimeclockEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -2650,6 +2662,88 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("CreditMemoApplications");
|
b.ToTable("CreditMemoApplications");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("DefaultRate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DiagramImagePath")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("FieldsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Formula")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsModifiedFromSource")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("OutputMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("RateLabel")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("SourceFormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceFormulaLibraryItemId");
|
||||||
|
|
||||||
|
b.ToTable("CustomItemTemplates");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2960,6 +3054,66 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("Deposits");
|
b.ToTable("Deposits");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ClockInTime")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClockOutTime")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("EntryType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("HoursWorked")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "ClockInTime");
|
||||||
|
|
||||||
|
b.ToTable("EmployeeClockEntries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3045,7 +3199,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("PurchasePrice")
|
b.Property<decimal>("PurchasePrice")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("RecommendedMaintenanceIntervalDays")
|
b.Property<int?>("RecommendedMaintenanceIntervalDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("SerialNumber")
|
b.Property<string>("SerialNumber")
|
||||||
@@ -3291,6 +3445,183 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("FixedAssetDepreciationEntries");
|
b.ToTable("FixedAssetDepreciationEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("FormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ImportedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("ImportedByUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("ResultingCustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FormulaLibraryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("ResultingCustomItemTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "FormulaLibraryItemId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryImports");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal?>("DefaultRate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DiagramImagePath")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("FieldsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Formula")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("ImportCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("IndustryHint")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("InspiredByFormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("OutputMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("RateLabel")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SharedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SharedByUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SourceCompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SourceCompanyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SourceCustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Tags")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InspiredByFormulaLibraryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("IsPublished")
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||||
|
|
||||||
|
b.HasIndex("SourceCompanyId")
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("FormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPositive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FormulaLibraryItemId", "CompanyId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryRatings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3938,6 +4269,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("PublicViewToken")
|
b.Property<string>("PublicViewToken")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -4229,6 +4563,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PricingBreakdownJson")
|
b.Property<string>("PricingBreakdownJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4473,6 +4810,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("CustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -4489,12 +4829,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Finish")
|
b.Property<string>("Finish")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("FormulaFieldValuesJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsAiItem")
|
b.Property<bool>("IsAiItem")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCustomFormulaItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -4558,6 +4904,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CatalogItemId");
|
b.HasIndex("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasIndex("JobId")
|
b.HasIndex("JobId")
|
||||||
.HasDatabaseName("IX_JobItems_JobId");
|
.HasDatabaseName("IX_JobItems_JobId");
|
||||||
|
|
||||||
@@ -6711,7 +7059,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
|
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6722,7 +7070,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
|
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6733,7 +7081,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
|
CreatedAt = new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7043,6 +7391,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("ProfitPercent")
|
b.Property<decimal>("ProfitPercent")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ProspectAddress")
|
b.Property<string>("ProspectAddress")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -7260,6 +7611,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("CustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -7273,12 +7627,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("EstimatedMinutes")
|
b.Property<int>("EstimatedMinutes")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("FormulaFieldValuesJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsAiItem")
|
b.Property<bool>("IsAiItem")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCustomFormulaItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -7348,6 +7708,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CatalogItemId");
|
b.HasIndex("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasIndex("QuoteId")
|
b.HasIndex("QuoteId")
|
||||||
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
||||||
|
|
||||||
@@ -7414,6 +7776,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("PowderCatalogItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("PowderCostPerLb")
|
b.Property<decimal?>("PowderCostPerLb")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -7990,6 +8355,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("ReworkJobId")
|
b.Property<int?>("ReworkJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("ReworkPricingType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("ReworkType")
|
b.Property<int>("ReworkType")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -8073,6 +8441,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("AllowAiPhotoQuotes")
|
b.Property<bool>("AllowAiPhotoQuotes")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowCustomFormulas")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("AllowOnlinePayments")
|
b.Property<bool>("AllowOnlinePayments")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -8249,6 +8620,61 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("TermsAcceptances");
|
b.ToTable("TermsAcceptances");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.TimeclockKioskDevice", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ActivatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeenAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TimeclockKioskDevices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -8631,6 +9057,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("YearEndCloses");
|
b.ToTable("YearEndCloses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("InventoryCategoryLookupId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("VendorId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("InventoryCategoryLookupId", "VendorId");
|
||||||
|
|
||||||
|
b.HasIndex("VendorId");
|
||||||
|
|
||||||
|
b.ToTable("VendorInventoryCategories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
@@ -9016,6 +9457,16 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Invoice");
|
b.Navigation("Invoice");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "SourceFormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SourceFormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("SourceFormulaLibraryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -9079,6 +9530,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("RecordedBy");
|
b.Navigation("RecordedBy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -9161,6 +9623,46 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "ResultingCustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ResultingCustomItemTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("FormulaLibraryItem");
|
||||||
|
|
||||||
|
b.Navigation("ResultingCustomItemTemplate");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "InspiredBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InspiredByFormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.Navigation("InspiredBy");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("FormulaLibraryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||||
@@ -9494,6 +9996,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.HasForeignKey("CatalogItemId")
|
.HasForeignKey("CatalogItemId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
|
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
|
||||||
.WithMany("JobItems")
|
.WithMany("JobItems")
|
||||||
.HasForeignKey("JobId")
|
.HasForeignKey("JobId")
|
||||||
@@ -9504,6 +10010,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("CatalogItem");
|
b.Navigation("CatalogItem");
|
||||||
|
|
||||||
|
b.Navigation("CustomItemTemplate");
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -10113,6 +10621,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CatalogItemId");
|
.HasForeignKey("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
|
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
|
||||||
.WithMany("QuoteItems")
|
.WithMany("QuoteItems")
|
||||||
.HasForeignKey("QuoteId")
|
.HasForeignKey("QuoteId")
|
||||||
@@ -10123,6 +10635,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("CatalogItem");
|
b.Navigation("CatalogItem");
|
||||||
|
|
||||||
|
b.Navigation("CustomItemTemplate");
|
||||||
|
|
||||||
b.Navigation("Quote");
|
b.Navigation("Quote");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -10369,6 +10883,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InventoryCategoryLookupId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VendorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillLineItems");
|
b.Navigation("BillLineItems");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
||||||
|
{
|
||||||
|
return await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.CountAsync(j => j.OriginalJobId == originalJobId);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
// Customer Intake Kiosk
|
// Customer Intake Kiosk
|
||||||
private IRepository<KioskSession>? _kioskSessions;
|
private IRepository<KioskSession>? _kioskSessions;
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
|
||||||
|
private IRepository<TimeclockKioskDevice>? _timeclockKioskDevices;
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
private IRepository<CustomItemTemplate>? _customItemTemplates;
|
||||||
|
|
||||||
|
// Formula Community Library
|
||||||
|
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
|
||||||
|
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
|
||||||
|
private IPlainRepository<FormulaLibraryRating>? _formulaLibraryRatings;
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
private IPurchaseOrderRepository? _purchaseOrders;
|
private IPurchaseOrderRepository? _purchaseOrders;
|
||||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||||
@@ -457,6 +469,30 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<KioskSession> KioskSessions =>
|
public IRepository<KioskSession> KioskSessions =>
|
||||||
_kioskSessions ??= new Repository<KioskSession>(_context);
|
_kioskSessions ??= new Repository<KioskSession>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="EmployeeClockEntry"/> facility-level clock-in/clock-out records; tenant-filtered with soft delete. Multiple entries per day are fully supported.</summary>
|
||||||
|
public IRepository<EmployeeClockEntry> EmployeeClockEntries =>
|
||||||
|
_employeeClockEntries ??= new Repository<EmployeeClockEntry>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="TimeclockKioskDevice"/> activated tablet records; one row per device. Delete a row to revoke that device's access.</summary>
|
||||||
|
public IRepository<TimeclockKioskDevice> TimeclockKioskDevices =>
|
||||||
|
_timeclockKioskDevices ??= new Repository<TimeclockKioskDevice>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<CustomItemTemplate> CustomItemTemplates =>
|
||||||
|
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="FormulaLibraryItem"/> community library entries; platform-level, no tenant filter.</summary>
|
||||||
|
public IPlainRepository<FormulaLibraryItem> FormulaLibrary =>
|
||||||
|
_formulaLibrary ??= new PlainRepository<FormulaLibraryItem>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="FormulaLibraryImport"/> per-company import records; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<FormulaLibraryImport> FormulaLibraryImports =>
|
||||||
|
_formulaLibraryImports ??= new Repository<FormulaLibraryImport>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="FormulaLibraryRating"/> per-company thumbs votes; platform-level, no tenant filter.</summary>
|
||||||
|
public IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings =>
|
||||||
|
_formulaLibraryRatings ??= new PlainRepository<FormulaLibraryRating>(_context);
|
||||||
|
|
||||||
// Job Templates
|
// Job Templates
|
||||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||||
public IJobTemplateRepository JobTemplates =>
|
public IJobTemplateRepository JobTemplates =>
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
JobNumber = "JOB-2601-0001",
|
JobNumber = "JOB-2601-0001",
|
||||||
CustomerEmail = "customer@example.com",
|
CustomerEmail = "customer@example.com",
|
||||||
CustomerName = "Acme Corp (used if email is blank or not found)",
|
CustomerName = "Acme Corp (used if email is blank or not found)",
|
||||||
|
Description = "Sample job description",
|
||||||
Status = "Pending",
|
Status = "Pending",
|
||||||
Priority = "Normal",
|
Priority = "Normal",
|
||||||
ScheduledDate = DateTime.Today.AddDays(7),
|
ScheduledDate = DateTime.Today.AddDays(7),
|
||||||
@@ -269,7 +270,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
FinalPrice = 750.00m,
|
FinalPrice = 750.00m,
|
||||||
CustomerPO = "PO-12345",
|
CustomerPO = "PO-12345",
|
||||||
SpecialInstructions = "Handle with care",
|
SpecialInstructions = "Handle with care",
|
||||||
Notes = "Sample job"
|
Notes = "Internal notes"
|
||||||
});
|
});
|
||||||
csv.NextRecord();
|
csv.NextRecord();
|
||||||
|
|
||||||
@@ -388,8 +389,15 @@ public class CsvImportService : ICsvImportService
|
|||||||
/// Imports customers from a CSV stream and persists valid rows to the database for the given company.
|
/// Imports customers from a CSV stream and persists valid rows to the database for the given company.
|
||||||
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
|
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
|
||||||
/// entity is saved individually so that a single bad row does not roll back the entire batch.
|
/// entity is saved individually so that a single bad row does not roll back the entire batch.
|
||||||
/// Duplicate detection runs against both existing DB records (by email) and within the import file
|
/// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous
|
||||||
/// itself, catching cases where the same email appears twice in one upload.
|
/// identifier is absent:
|
||||||
|
/// Tier 1 — email address (case-insensitive): if email is present and matches a DB record or
|
||||||
|
/// earlier batch row the row is skipped.
|
||||||
|
/// Tier 2 — name + normalised phone composite: used when email is absent. Combining name with
|
||||||
|
/// phone prevents false positives when two people share a number (e.g. a family).
|
||||||
|
/// Row is skipped on match.
|
||||||
|
/// Tier 3 — name + city/state/zip composite: used when both email and phone are absent.
|
||||||
|
/// Location data is imprecise so this emits a warning but still imports the row.
|
||||||
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
|
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
|
||||||
/// customer is imported without a tier rather than being skipped entirely.
|
/// customer is imported without a tier rather than being skipped entirely.
|
||||||
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a
|
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a
|
||||||
@@ -418,15 +426,53 @@ public class CsvImportService : ICsvImportService
|
|||||||
|
|
||||||
// Get all existing customers for duplicate detection
|
// Get all existing customers for duplicate detection
|
||||||
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
|
|
||||||
|
// Tier 1 lookup: email → existing customer
|
||||||
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
|
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||||
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Tier 2 lookup: (normalised phone + "|" + display name) → existing customer.
|
||||||
|
// Combining name with phone avoids false positives when two people share a number.
|
||||||
|
var existingByPhoneAndName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in existingCustomers)
|
||||||
|
{
|
||||||
|
var phone = NormalizePhone(c.MobilePhone) ?? NormalizePhone(c.Phone);
|
||||||
|
if (phone == null) continue;
|
||||||
|
var name = string.IsNullOrWhiteSpace(c.CompanyName)
|
||||||
|
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||||||
|
: c.CompanyName;
|
||||||
|
var key = $"{phone}|{name}";
|
||||||
|
if (!existingByPhoneAndName.ContainsKey(key))
|
||||||
|
existingByPhoneAndName[key] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3 lookup: (display name + "|" + city + "|" + state + "|" + zip) → existing customer.
|
||||||
|
// Only populated when a customer has at least one location field so the key isn't trivially blank.
|
||||||
|
var existingByNameAndLocation = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in existingCustomers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(c.City) && string.IsNullOrWhiteSpace(c.State) && string.IsNullOrWhiteSpace(c.ZipCode))
|
||||||
|
continue;
|
||||||
|
var name = string.IsNullOrWhiteSpace(c.CompanyName)
|
||||||
|
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||||||
|
: c.CompanyName;
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
var key = $"{name}|{c.City}|{c.State}|{c.ZipCode}";
|
||||||
|
if (!existingByNameAndLocation.ContainsKey(key))
|
||||||
|
existingByNameAndLocation[key] = c;
|
||||||
|
}
|
||||||
|
|
||||||
// Get pricing tiers for lookup
|
// Get pricing tiers for lookup
|
||||||
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
||||||
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
|
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
|
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
|
||||||
|
|
||||||
|
// Within-batch tracking sets (prevent duplicate detection against rows already queued)
|
||||||
|
var batchEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var batchPhoneAndName = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var batchNameAndLocation = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
{
|
{
|
||||||
rowNumber++;
|
rowNumber++;
|
||||||
@@ -434,7 +480,12 @@ public class CsvImportService : ICsvImportService
|
|||||||
{
|
{
|
||||||
// Strip any literal quote characters that QB/Excel may wrap around field values
|
// Strip any literal quote characters that QB/Excel may wrap around field values
|
||||||
var cleanCompanyName = StripQuotes(record.CompanyName);
|
var cleanCompanyName = StripQuotes(record.CompanyName);
|
||||||
var cleanEmail = StripQuotes(record.Email);
|
// Normalise to null (not empty string) — the UNIQUE index on (CompanyId, Email)
|
||||||
|
// uses HasFilter("[Email] IS NOT NULL"), so NULL is allowed for multiple rows
|
||||||
|
// but "" (empty string) is not NULL and would cause a unique-constraint violation
|
||||||
|
// on the second blank-email customer saved.
|
||||||
|
var rawEmail = StripQuotes(record.Email);
|
||||||
|
var cleanEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail;
|
||||||
var firstName = StripQuotes(record.ContactFirstName)?.Trim();
|
var firstName = StripQuotes(record.ContactFirstName)?.Trim();
|
||||||
var lastName = StripQuotes(record.ContactLastName)?.Trim();
|
var lastName = StripQuotes(record.ContactLastName)?.Trim();
|
||||||
|
|
||||||
@@ -451,21 +502,69 @@ public class CsvImportService : ICsvImportService
|
|||||||
cleanCompanyName = derivedName;
|
cleanCompanyName = derivedName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate email in existing data
|
// Canonical display name used as part of composite keys in Tiers 2 and 3
|
||||||
if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower()))
|
var displayName = string.IsNullOrWhiteSpace(cleanCompanyName)
|
||||||
|
? $"{firstName} {lastName}".Trim()
|
||||||
|
: cleanCompanyName;
|
||||||
|
|
||||||
|
// --- Tier 1: email dedup ---
|
||||||
|
// Only engaged when the row has an email address.
|
||||||
|
if (!string.IsNullOrEmpty(cleanEmail))
|
||||||
|
{
|
||||||
|
if (existingEmails.ContainsKey(cleanEmail.ToLower()))
|
||||||
{
|
{
|
||||||
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
|
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
|
||||||
result.SkippedCount++;
|
result.SkippedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (batchEmails.Contains(cleanEmail))
|
||||||
// Check for duplicate email within the import batch
|
|
||||||
if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase)))
|
|
||||||
{
|
{
|
||||||
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
|
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
|
||||||
result.SkippedCount++;
|
result.SkippedCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// --- Tier 2: name + phone composite dedup (email absent) ---
|
||||||
|
// Requiring both name and phone to match avoids false positives when two
|
||||||
|
// unrelated people happen to share a phone number (e.g. a shared office line).
|
||||||
|
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
|
||||||
|
if (normalizedPhone != null)
|
||||||
|
{
|
||||||
|
var phoneNameKey = $"{normalizedPhone}|{displayName}";
|
||||||
|
if (existingByPhoneAndName.TryGetValue(phoneNameKey, out var existingMatch))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; name + phone matches existing customer '{existingMatch.CompanyName}'. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (batchPhoneAndName.Contains(phoneNameKey))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; duplicate name + phone found in import file. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// --- Tier 3: name + location composite warning (no email, no phone) ---
|
||||||
|
// Location data is imprecise so we warn but still import — a name + city
|
||||||
|
// collision across unrelated people is plausible enough not to hard-skip.
|
||||||
|
var city = record.City?.Trim();
|
||||||
|
var state = record.State?.Trim();
|
||||||
|
var zip = record.ZipCode?.Trim();
|
||||||
|
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
|
||||||
|
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
var locationKey = $"{displayName}|{city}|{state}|{zip}";
|
||||||
|
if (existingByNameAndLocation.ContainsKey(locationKey) || batchNameAndLocation.Contains(locationKey))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email or phone; name + location matches an existing record. Imported anyway — verify manually.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve pricing tier
|
// Resolve pricing tier
|
||||||
int? pricingTierId = null;
|
int? pricingTierId = null;
|
||||||
@@ -512,6 +611,29 @@ public class CsvImportService : ICsvImportService
|
|||||||
};
|
};
|
||||||
|
|
||||||
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
|
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
|
||||||
|
|
||||||
|
// Register in batch tracking so later rows are checked against this one
|
||||||
|
if (!string.IsNullOrEmpty(cleanEmail))
|
||||||
|
{
|
||||||
|
batchEmails.Add(cleanEmail);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
|
||||||
|
if (normalizedPhone != null)
|
||||||
|
{
|
||||||
|
batchPhoneAndName.Add($"{normalizedPhone}|{displayName}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var city = record.City?.Trim();
|
||||||
|
var state = record.State?.Trim();
|
||||||
|
var zip = record.ZipCode?.Trim();
|
||||||
|
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
|
||||||
|
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
|
||||||
|
batchNameAndLocation.Add($"{displayName}|{city}|{state}|{zip}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1268,24 +1390,22 @@ public class CsvImportService : ICsvImportService
|
|||||||
MissingFieldFound = null
|
MissingFieldFound = null
|
||||||
});
|
});
|
||||||
|
|
||||||
var records = csv.GetRecords<JobImportDto>().ToList();
|
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
|
||||||
result.TotalRows = records.Count;
|
// as null rather than throwing a fatal TypeConverterException.
|
||||||
|
csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());
|
||||||
|
|
||||||
_logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId);
|
// Read header row first so we know field count before iterating rows.
|
||||||
|
await csv.ReadAsync();
|
||||||
|
csv.ReadHeader();
|
||||||
|
|
||||||
// Get all existing jobs for duplicate detection
|
// Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
|
||||||
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
|
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||||
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||||
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Get customers for lookup — build two dictionaries so we can resolve by email
|
|
||||||
// first and fall back to company name when the customer has no email on file.
|
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||||
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
||||||
// Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
|
|
||||||
// TryAdd ensures that if two customers share the same name the first one wins and the
|
|
||||||
// lookup warning will prompt the user to resolve the ambiguity manually.
|
|
||||||
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var c in customers)
|
foreach (var c in customers)
|
||||||
{
|
{
|
||||||
@@ -1296,19 +1416,42 @@ public class CsvImportService : ICsvImportService
|
|||||||
customerByName.TryAdd(name, c);
|
customerByName.TryAdd(name, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get job statuses for lookup
|
|
||||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||||
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Get job priorities for lookup
|
|
||||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||||
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
|
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
|
||||||
|
|
||||||
foreach (var record in records)
|
// Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
|
||||||
|
// in a decimal field) is caught per-row rather than aborting the entire import.
|
||||||
|
while (await csv.ReadAsync())
|
||||||
{
|
{
|
||||||
rowNumber++;
|
result.TotalRows++;
|
||||||
|
JobImportDto record;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
record = csv.GetRecord<JobImportDto>()
|
||||||
|
?? throw new InvalidOperationException("Row returned null record.");
|
||||||
|
}
|
||||||
|
catch (Exception parseEx)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
_logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
|
||||||
|
|
||||||
|
// Warn when FinalPrice was non-numeric (lenient converter returned null).
|
||||||
|
var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
|
||||||
|
&& !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -1414,7 +1557,9 @@ public class CsvImportService : ICsvImportService
|
|||||||
CustomerPO = record.CustomerPO?.Trim(),
|
CustomerPO = record.CustomerPO?.Trim(),
|
||||||
SpecialInstructions = record.SpecialInstructions?.Trim(),
|
SpecialInstructions = record.SpecialInstructions?.Trim(),
|
||||||
InternalNotes = record.Notes?.Trim(),
|
InternalNotes = record.Notes?.Trim(),
|
||||||
Description = record.SpecialInstructions?.Trim() ?? "Imported job",
|
Description = record.Description?.Trim()
|
||||||
|
?? record.SpecialInstructions?.Trim()
|
||||||
|
?? "Imported job",
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
@@ -2813,6 +2958,23 @@ public class CsvImportService : ICsvImportService
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalises a phone string to its last 10 digits for duplicate-detection comparisons.
|
||||||
|
/// Stripping to digits only means formatting differences such as (423) 331-9834,
|
||||||
|
/// 423-331-9834, and 4233319834 all produce the same key. Returns null when the input
|
||||||
|
/// contains fewer than 7 digits — too short to be a real phone number and avoids false
|
||||||
|
/// positive matches on placeholder values like "N/A" or extension-only strings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="phone">Raw phone string as read from the CSV, or null.</param>
|
||||||
|
/// <returns>Last 10 (or all, if fewer than 10) digits of the input; null if input is unusable.</returns>
|
||||||
|
private static string? NormalizePhone(string? phone)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(phone)) return null;
|
||||||
|
var digits = new string(phone.Where(char.IsDigit).ToArray());
|
||||||
|
if (digits.Length < 7) return null;
|
||||||
|
return digits.Length >= 10 ? digits[^10..] : digits;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Invoice Import ───────────────────────────────────────────────────────────
|
// ── Invoice Import ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3340,4 +3502,23 @@ public class CsvImportService : ICsvImportService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
|
||||||
|
/// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
|
||||||
|
/// artefacts like "false" in a price column are treated as $0 with a warning.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
|
||||||
|
{
|
||||||
|
public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
|
||||||
|
? (object?)v
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||||
|
=> value?.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using NCalc2;
|
||||||
|
using Anthropic.SDK;
|
||||||
|
using Anthropic.SDK.Messaging;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates NCalc pricing formula templates from natural-language descriptions using
|
||||||
|
/// Claude Sonnet. Accepts an optional diagram image so the model can see the physical
|
||||||
|
/// shape being estimated. The model returns a structured JSON object containing the
|
||||||
|
/// field list, NCalc expression, output mode, and verification inputs; the service
|
||||||
|
/// parses and returns it as a <see cref="GenerateFormulaFromAiResponse"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomFormulaAiService : ICustomFormulaAiService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ILogger<CustomFormulaAiService> _logger;
|
||||||
|
|
||||||
|
private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business.
|
||||||
|
Your job is to generate NCalc expressions that calculate either a fixed price or a surface area
|
||||||
|
from user-supplied field values.
|
||||||
|
|
||||||
|
CRITICAL: NCalc function names are CASE-SENSITIVE and must be ALL LOWERCASE.
|
||||||
|
Supported built-in functions (always write these exactly as shown):
|
||||||
|
if(condition, trueValue, falseValue) — conditional expression
|
||||||
|
abs(x) — absolute value
|
||||||
|
round(x, digits) — round to N decimal places
|
||||||
|
max(a, b) — larger of two values
|
||||||
|
min(a, b) — smaller of two values
|
||||||
|
pow(base, exponent) — exponentiation
|
||||||
|
sqrt(x) — square root
|
||||||
|
Standard operators: + - * / %
|
||||||
|
Comparison operators: < > <= >= == !=
|
||||||
|
Boolean operators: && || !
|
||||||
|
|
||||||
|
Do NOT use: IF, Abs, Round, Max, Min, Pow, Sqrt (uppercase versions) — NCalc will reject them.
|
||||||
|
|
||||||
|
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
|
||||||
|
'Tubular frame') and you must produce a pricing formula template.
|
||||||
|
|
||||||
|
Respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation:
|
||||||
|
|
||||||
|
{
|
||||||
|
""name"": ""string — short template name (e.g. 'Roof Curb', 'Electrical Enclosure')"",
|
||||||
|
""outputMode"": ""FixedRate"" | ""SurfaceAreaSqFt"",
|
||||||
|
""fields"": [
|
||||||
|
{
|
||||||
|
""name"": ""snake_case_variable_name"",
|
||||||
|
""label"": ""Human-readable label"",
|
||||||
|
""unit"": ""in / ft / mm / cm / qty / lbs — or empty string"",
|
||||||
|
""defaultValue"": number
|
||||||
|
}
|
||||||
|
],
|
||||||
|
""formula"": ""NCalc expression using field name variables and optionally 'rate'"",
|
||||||
|
""defaultRate"": number or null,
|
||||||
|
""rateLabel"": ""string label for the rate field, e.g. '$/sq ft' — null if no rate"",
|
||||||
|
""reasoning"": ""1-2 sentences explaining how the formula was derived"",
|
||||||
|
""verificationInputs"": { ""variable_name"": number },
|
||||||
|
""verificationResult"": number
|
||||||
|
}
|
||||||
|
|
||||||
|
Built-in shop-rate variables (injected automatically at eval time — do NOT redeclare them as fields):
|
||||||
|
standard_labor_rate — shop billing rate in $/hr (e.g. hours * standard_labor_rate)
|
||||||
|
additional_coat_labor_pct — extra-coat labor surcharge 0–100 (e.g. cost * (1 + additional_coat_labor_pct/100))
|
||||||
|
markup_pct — general markup percentage 0–100 (e.g. cost * (1 + markup_pct/100))
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
|
||||||
|
- Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest
|
||||||
|
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions, UNLESS the formula already uses standard_labor_rate or another built-in
|
||||||
|
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
|
||||||
|
- Do NOT include standard_labor_rate, additional_coat_labor_pct, or markup_pct in the fields array — they are injected automatically
|
||||||
|
- verificationInputs and verificationResult must use the exact field names and formula you generated
|
||||||
|
- Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed
|
||||||
|
- For inch inputs convert to sqft: divide by 144 (sqin→sqft) or use /12 per side before multiplying
|
||||||
|
";
|
||||||
|
|
||||||
|
public CustomFormulaAiService(IConfiguration config, ILogger<CustomFormulaAiService> logger)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
|
||||||
|
GenerateFormulaFromAiRequest request,
|
||||||
|
byte[]? imageBytes = null,
|
||||||
|
string? imageContentType = null)
|
||||||
|
{
|
||||||
|
var apiKey = _config["AI:Anthropic:ApiKey"];
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
||||||
|
{
|
||||||
|
return new GenerateFormulaFromAiResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new AnthropicClient(apiKey);
|
||||||
|
|
||||||
|
var userContent = new List<ContentBase>();
|
||||||
|
|
||||||
|
if (imageBytes is { Length: > 0 } && !string.IsNullOrWhiteSpace(imageContentType))
|
||||||
|
{
|
||||||
|
userContent.Add(new ImageContent
|
||||||
|
{
|
||||||
|
Source = new ImageSource
|
||||||
|
{
|
||||||
|
MediaType = imageContentType,
|
||||||
|
Data = Convert.ToBase64String(imageBytes)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
userContent.Add(new TextContent { Text = request.Description });
|
||||||
|
|
||||||
|
var messages = new List<Message>
|
||||||
|
{
|
||||||
|
new() { Role = RoleType.User, Content = userContent }
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters
|
||||||
|
{
|
||||||
|
Model = "claude-sonnet-4-6",
|
||||||
|
MaxTokens = 1024,
|
||||||
|
SystemMessage = SystemPrompt,
|
||||||
|
Messages = messages
|
||||||
|
});
|
||||||
|
|
||||||
|
var rawJson = response.Message.ToString().Trim();
|
||||||
|
|
||||||
|
// Strip markdown code fences if the model adds them
|
||||||
|
if (rawJson.StartsWith("```"))
|
||||||
|
{
|
||||||
|
var start = rawJson.IndexOf('\n') + 1;
|
||||||
|
var end = rawJson.LastIndexOf("```");
|
||||||
|
if (end > start) rawJson = rawJson[start..end].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(rawJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var fieldsJson = root.TryGetProperty("fields", out var fieldsEl)
|
||||||
|
? fieldsEl.GetRawText()
|
||||||
|
: "[]";
|
||||||
|
|
||||||
|
decimal? defaultRate = null;
|
||||||
|
if (root.TryGetProperty("defaultRate", out var rateEl) && rateEl.ValueKind == JsonValueKind.Number)
|
||||||
|
defaultRate = rateEl.GetDecimal();
|
||||||
|
|
||||||
|
decimal? verificationResult = null;
|
||||||
|
if (root.TryGetProperty("verificationResult", out var vrEl) && vrEl.ValueKind == JsonValueKind.Number)
|
||||||
|
verificationResult = vrEl.GetDecimal();
|
||||||
|
|
||||||
|
string? verificationInputs = null;
|
||||||
|
if (root.TryGetProperty("verificationInputs", out var viEl))
|
||||||
|
verificationInputs = viEl.GetRawText();
|
||||||
|
|
||||||
|
return new GenerateFormulaFromAiResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
|
||||||
|
OutputMode = root.TryGetProperty("outputMode", out var omEl) ? omEl.GetString() : "FixedRate",
|
||||||
|
FieldsJson = fieldsJson,
|
||||||
|
Formula = root.TryGetProperty("formula", out var fEl) ? fEl.GetString() : null,
|
||||||
|
DefaultRate = defaultRate,
|
||||||
|
RateLabel = root.TryGetProperty("rateLabel", out var rlEl) ? rlEl.GetString() : null,
|
||||||
|
Reasoning = root.TryGetProperty("reasoning", out var reEl) ? reEl.GetString() : null,
|
||||||
|
VerificationResult = verificationResult,
|
||||||
|
VerificationInputs = verificationInputs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "CustomFormulaAiService.GenerateFormulaAsync failed");
|
||||||
|
return new GenerateFormulaFromAiResponse { Success = false, Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lowercase NCalc built-in names before evaluation so that user-typed or AI-generated
|
||||||
|
// uppercase variants (IF, Abs, POW, etc.) don't produce "Function not found" errors.
|
||||||
|
private static readonly Regex _ncalcFuncRegex = new(
|
||||||
|
@"\b(if|abs|round|max|min|pow|sqrt|ceiling|floor|truncate|sign|log|exp)\b(?=\s*\()",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static string NormalizeFormula(string formula) =>
|
||||||
|
_ncalcFuncRegex.Replace(formula, m => m.Value.ToLowerInvariant());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public (string NormalizedFormula, string? Error) NormalizeAndValidate(string formula)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(formula))
|
||||||
|
return (formula, "Formula cannot be empty.");
|
||||||
|
|
||||||
|
var normalized = NormalizeFormula(formula);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var expr = new Expression(normalized);
|
||||||
|
if (expr.HasErrors())
|
||||||
|
return (formula, expr.Error);
|
||||||
|
return (normalized, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (formula, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request?.Formula))
|
||||||
|
return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
||||||
|
request.VariablesJson ?? "{}") ?? new();
|
||||||
|
|
||||||
|
var expr = new Expression(NormalizeFormula(request.Formula));
|
||||||
|
foreach (var kv in variables)
|
||||||
|
{
|
||||||
|
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
||||||
|
? (object)kv.Value.GetDouble()
|
||||||
|
: (object)(kv.Value.GetString() ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = expr.Evaluate();
|
||||||
|
var decResult = Convert.ToDecimal(result);
|
||||||
|
return new EvaluateFormulaResponse { Success = true, Result = Math.Round(decResult, 4) };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new EvaluateFormulaResponse { Success = false, Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error) = await _emailService.SendEmailAsync(
|
var (success, error) = await _emailService.SendEmailAsync(
|
||||||
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
|
|||||||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||||||
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
|
|||||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||||
job.CompanyId, notifType, values, defaultSubject);
|
job.CompanyId, notifType, values, defaultSubject);
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
|
|||||||
job.CompanyId, NotificationType.JobCompleted, values,
|
job.CompanyId, NotificationType.JobCompleted, values,
|
||||||
$"Job {job.JobNumber} Complete — {companyName}");
|
$"Job {job.JobNumber} Complete — {companyName}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
|
|||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
||||||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||||||
: StripHtml(fullHtml);
|
: StripHtml(fullHtml);
|
||||||
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
|
|||||||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||||||
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
|
|||||||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||||||
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||||
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
|
|||||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||||
quote.CompanyId, notificationType, values, defaultSubject);
|
quote.CompanyId, notificationType, values, defaultSubject);
|
||||||
|
|
||||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
|
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
|
||||||
var plainText = StripHtml(fullHtml);
|
var plainText = StripHtml(fullHtml);
|
||||||
|
|
||||||
var (success, error) = await _emailService.SendEmailAsync(
|
var (success, error) = await _emailService.SendEmailAsync(
|
||||||
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
|
|||||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||||
|
|
||||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||||
var custPlainText = StripHtml(custFullHtml);
|
var custPlainText = StripHtml(custFullHtml);
|
||||||
|
|
||||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||||
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Appends CAN-SPAM required footer as HTML.
|
/// Appends CAN-SPAM required footer as HTML.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
|
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
|
||||||
{
|
{
|
||||||
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
||||||
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
||||||
|
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
|
||||||
|
|
||||||
if (!hasUnsubscribeUrl && !hasAddress)
|
if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
|
||||||
return htmlBody;
|
return htmlBody;
|
||||||
|
|
||||||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
||||||
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
||||||
|
|
||||||
|
if (hasReplyTo)
|
||||||
|
{
|
||||||
|
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
|
||||||
|
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
|
||||||
|
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
|
||||||
|
}
|
||||||
|
|
||||||
if (hasAddress)
|
if (hasAddress)
|
||||||
{
|
{
|
||||||
var addressLine = BuildAddressLine(company!);
|
var addressLine = BuildAddressLine(company!);
|
||||||
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
|
||||||
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
|
var email = prefs?.EmailFromAddress;
|
||||||
|
var name = prefs?.EmailFromName;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(email))
|
||||||
|
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
|
||||||
|
else
|
||||||
|
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
|
||||||
|
|
||||||
|
return (email, name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -67,9 +67,9 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a DI scope, queries non-Stripe-managed companies with active or grace-period subscriptions,
|
/// Opens a DI scope, queries non-Stripe-managed (trial) companies with active or grace-period
|
||||||
/// and calls <see cref="ProcessCompanyAsync"/> for each. A single <c>SaveChangesAsync</c> at the
|
/// subscriptions, and calls <see cref="ProcessCompanyAsync"/> for each. Each company is saved
|
||||||
/// end batches all status mutations into one round-trip. Errors are caught to keep the loop alive.
|
/// individually so a single failure does not prevent other companies from being updated.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task RunAsync(CancellationToken ct)
|
private async Task RunAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -103,17 +103,29 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
|
|
||||||
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
|
_logger.LogDebug("Found {Count} companies to evaluate.", companies.Count);
|
||||||
|
|
||||||
|
// All companies reaching this point have no StripeSubscriptionId — they are trials.
|
||||||
|
// Paid subscribers are managed by Stripe and filtered out above.
|
||||||
|
var effectiveGraceDays = gracePeriodAppliesToTrials ? gracePeriodDays : 0;
|
||||||
|
|
||||||
foreach (var company in companies)
|
foreach (var company in companies)
|
||||||
{
|
{
|
||||||
if (ct.IsCancellationRequested) break;
|
if (ct.IsCancellationRequested) break;
|
||||||
var isTrial = string.IsNullOrEmpty(company.StripeSubscriptionId);
|
try
|
||||||
var effectiveGraceDays = isTrial && !gracePeriodAppliesToTrials ? 0 : gracePeriodDays;
|
{
|
||||||
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
|
await ProcessCompanyAsync(db, emailService, adminNotification, company, today, effectiveGraceDays, ct);
|
||||||
}
|
|
||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
await db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"Failed to process subscription expiry for company {Id} ({Name}). Status change was not persisted.",
|
||||||
|
company.Id, company.CompanyName);
|
||||||
|
// Clear EF tracked changes so bad state does not bleed into the next company.
|
||||||
|
db.ChangeTracker.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Subscription expiry run failed.");
|
_logger.LogError(ex, "Subscription expiry run failed.");
|
||||||
}
|
}
|
||||||
@@ -121,10 +133,15 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Evaluates a single company and performs any required status transitions or reminder sends.
|
/// Evaluates a single company and performs any required status transitions or reminder sends.
|
||||||
/// Transition logic: Active past end date → GracePeriod; GracePeriod past grace deadline → Expired + deactivated.
|
/// Transition logic:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Active past end date, grace days = 0 → Expired + deactivated immediately (trials).</item>
|
||||||
|
/// <item>Active past end date, grace days > 0 → GracePeriod + grace-period email.</item>
|
||||||
|
/// <item>GracePeriod past grace deadline → Expired + deactivated.</item>
|
||||||
|
/// </list>
|
||||||
/// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active.
|
/// Reminder emails at <see cref="ReminderDays"/> offsets are sent only while the company is still Active.
|
||||||
/// Platform admin is notified asynchronously (fire-and-forget) for both grace period start and full expiry
|
/// Platform admin is notified asynchronously (fire-and-forget) so that operator action can be taken
|
||||||
/// so that operator action can be taken without delaying the main processing loop.
|
/// without delaying the main processing loop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ProcessCompanyAsync(
|
private async Task ProcessCompanyAsync(
|
||||||
ApplicationDbContext db,
|
ApplicationDbContext db,
|
||||||
@@ -153,17 +170,38 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
await WriteAuditLogAsync(db, company,
|
await WriteAuditLogAsync(db, company,
|
||||||
$"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}.");
|
$"Auto-expired: grace period ended {gracePeriodDays} days after subscription end {endDate:d}.");
|
||||||
|
|
||||||
// Notify platform admin
|
|
||||||
_ = adminNotification.NotifyCompanyExpiredAsync(
|
_ = adminNotification.NotifyCompanyExpiredAsync(
|
||||||
company.Id, company.CompanyName,
|
company.Id, company.CompanyName,
|
||||||
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
||||||
}
|
}
|
||||||
else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate)
|
else if (company.SubscriptionStatus == SubscriptionStatus.Active && today > endDate)
|
||||||
{
|
{
|
||||||
|
if (gracePeriodDays == 0)
|
||||||
|
{
|
||||||
|
// No grace period configured — expire immediately without going through GracePeriod.
|
||||||
|
// Trials always land here since gracePeriodAppliesToTrials defaults to false.
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Company {Id} ({Name}) subscription ended. Entering grace period.",
|
"Company {Id} ({Name}) subscription ended with no grace period. Marking Expired and deactivating.",
|
||||||
company.Id, company.CompanyName);
|
company.Id, company.CompanyName);
|
||||||
|
|
||||||
|
company.SubscriptionStatus = SubscriptionStatus.Expired;
|
||||||
|
company.IsActive = false;
|
||||||
|
company.UpdatedAt = DateTime.UtcNow;
|
||||||
|
company.UpdatedBy = "System";
|
||||||
|
|
||||||
|
await WriteAuditLogAsync(db, company,
|
||||||
|
$"Auto-expired: subscription ended {endDate:d} with no grace period.");
|
||||||
|
|
||||||
|
_ = adminNotification.NotifyCompanyExpiredAsync(
|
||||||
|
company.Id, company.CompanyName,
|
||||||
|
company.PrimaryContactEmail ?? string.Empty, endDate);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Company {Id} ({Name}) subscription ended. Entering {Days}-day grace period.",
|
||||||
|
company.Id, company.CompanyName, gracePeriodDays);
|
||||||
|
|
||||||
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
|
company.SubscriptionStatus = SubscriptionStatus.GracePeriod;
|
||||||
company.UpdatedAt = DateTime.UtcNow;
|
company.UpdatedAt = DateTime.UtcNow;
|
||||||
company.UpdatedBy = "System";
|
company.UpdatedBy = "System";
|
||||||
@@ -171,18 +209,17 @@ public class SubscriptionExpiryBackgroundService : BackgroundService
|
|||||||
await WriteAuditLogAsync(db, company,
|
await WriteAuditLogAsync(db, company,
|
||||||
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
|
$"Auto-moved to GracePeriod: subscription ended {endDate:d}. Grace period expires {expiredDate:d}.");
|
||||||
|
|
||||||
// Send "grace period started" email to company immediately
|
|
||||||
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
await SendEmailIfNotSentAsync(db, emailService, company, today,
|
||||||
NotificationType.SubscriptionExpiryReminder,
|
NotificationType.SubscriptionExpiryReminder,
|
||||||
daysBeforeExpiry: 0,
|
daysBeforeExpiry: 0,
|
||||||
gracePeriodDays,
|
gracePeriodDays,
|
||||||
ct);
|
ct);
|
||||||
|
|
||||||
// Notify platform admin
|
|
||||||
_ = adminNotification.NotifyCompanyGracePeriodAsync(
|
_ = adminNotification.NotifyCompanyGracePeriodAsync(
|
||||||
company.Id, company.CompanyName,
|
company.Id, company.CompanyName,
|
||||||
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
company.PrimaryContactEmail ?? string.Empty, expiredDate);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reminder emails (only while still Active) ────────────────────
|
// ── Reminder emails (only while still Active) ────────────────────
|
||||||
if (company.SubscriptionStatus == SubscriptionStatus.Active)
|
if (company.SubscriptionStatus == SubscriptionStatus.Active)
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class CompanySettingsController : Controller
|
|||||||
private readonly IAuditLogService _auditLog;
|
private readonly IAuditLogService _auditLog;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
private readonly IAzureBlobStorageService _blobStorage;
|
||||||
|
private readonly ICustomFormulaAiService _formulaAiService;
|
||||||
|
private readonly IFormulaLibraryService _formulaLibraryService;
|
||||||
|
|
||||||
public CompanySettingsController(
|
public CompanySettingsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -45,7 +48,10 @@ public class CompanySettingsController : Controller
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IAuditLogService auditLog,
|
IAuditLogService auditLog,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
SignInManager<ApplicationUser> signInManager)
|
SignInManager<ApplicationUser> signInManager,
|
||||||
|
IAzureBlobStorageService blobStorage,
|
||||||
|
ICustomFormulaAiService formulaAiService,
|
||||||
|
IFormulaLibraryService formulaLibraryService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -58,6 +64,9 @@ public class CompanySettingsController : Controller
|
|||||||
_auditLog = auditLog;
|
_auditLog = auditLog;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
|
_blobStorage = blobStorage;
|
||||||
|
_formulaAiService = formulaAiService;
|
||||||
|
_formulaLibraryService = formulaLibraryService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -130,12 +139,20 @@ public class CompanySettingsController : Controller
|
|||||||
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
|
||||||
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
|
||||||
dto.AllowSms = planConfig?.AllowSms ?? false;
|
dto.AllowSms = planConfig?.AllowSms ?? false;
|
||||||
|
ViewBag.AllowCustomFormulas = AllowCustomFormulas();
|
||||||
dto.SmsEnabled = company.SmsEnabled;
|
dto.SmsEnabled = company.SmsEnabled;
|
||||||
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
|
dto.SmsDisabledByAdmin = company.SmsDisabledByAdmin;
|
||||||
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
|
dto.SmsTermsVersion = AppConstants.SmsTermsVersion;
|
||||||
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
|
dto.HasCurrentSmsAgreement = await _unitOfWork.CompanySmsAgreements
|
||||||
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
|
.AnyAsync(a => a.CompanyId == companyId.Value && a.TermsVersion == AppConstants.SmsTermsVersion);
|
||||||
|
|
||||||
|
// Timeclock settings
|
||||||
|
dto.TimeclockEnabled = company.TimeclockEnabled;
|
||||||
|
dto.TimeclockAllowMultiplePunchesPerDay = company.TimeclockAllowMultiplePunchesPerDay;
|
||||||
|
dto.TimeclockAutoClockOutHours = company.TimeclockAutoClockOutHours;
|
||||||
|
var kioskDevices = await _unitOfWork.TimeclockKioskDevices.FindAsync(d => d.CompanyId == companyId.Value);
|
||||||
|
ViewBag.TimeclockKioskDevices = kioskDevices.OrderBy(d => d.ActivatedAt).ToList();
|
||||||
|
|
||||||
// Flag whether Stripe Connect is configured (non-placeholder client ID)
|
// Flag whether Stripe Connect is configured (non-placeholder client ID)
|
||||||
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
|
var connectClientId = _configuration["Stripe:Connect:ConnectClientId"];
|
||||||
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
|
ViewBag.StripeConnectConfigured = !string.IsNullOrWhiteSpace(connectClientId)
|
||||||
@@ -734,6 +751,40 @@ public class CompanySettingsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST: CompanySettings/UpdateTimeclockSettings
|
||||||
|
/// <summary>
|
||||||
|
/// Saves company-level timeclock settings (enabled toggle, multiple-punches-per-day, auto clock-out hours).
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> UpdateTimeclockSettings([FromBody] UpdateTimeclockSettingsDto dto)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null)
|
||||||
|
return Json(new { success = false, message = "User does not have a company ID." });
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
|
||||||
|
if (company == null)
|
||||||
|
return Json(new { success = false, message = "Company not found." });
|
||||||
|
|
||||||
|
company.TimeclockEnabled = dto.TimeclockEnabled;
|
||||||
|
company.TimeclockAllowMultiplePunchesPerDay = dto.TimeclockAllowMultiplePunchesPerDay;
|
||||||
|
company.TimeclockAutoClockOutHours = dto.TimeclockAutoClockOutHours;
|
||||||
|
|
||||||
|
await _unitOfWork.Companies.UpdateAsync(company);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Company {CompanyId} timeclock settings updated", companyId);
|
||||||
|
return Json(new { success = true, message = "Timeclock settings saved." });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error updating timeclock settings");
|
||||||
|
return Json(new { success = false, message = "An error occurred while saving timeclock settings." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
|
/// Builds a suggested AI profile draft from existing company configuration — company name/location,
|
||||||
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
|
/// named ovens, sandblasting capability, shop worker roles, coating inventory categories, and
|
||||||
@@ -1675,6 +1726,26 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
#region Blast Setups
|
#region Blast Setups
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single authoritative blast-rate calculation endpoint. Takes equipment parameters and
|
||||||
|
/// returns the sqft/hr rate using the same ShopCapabilityCalculator formula the AI uses.
|
||||||
|
/// The modal live preview calls this instead of duplicating the formula in JavaScript.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult DeriveBlastRate(decimal cfm, int nozzle, int setupType, int substrate, decimal? rateOverride)
|
||||||
|
{
|
||||||
|
var setup = new CompanyBlastSetup
|
||||||
|
{
|
||||||
|
CompressorCfm = cfm,
|
||||||
|
BlastNozzleSize = nozzle,
|
||||||
|
SetupType = (BlastSetupType)setupType,
|
||||||
|
PrimarySubstrate = (BlastSubstrateType)substrate,
|
||||||
|
BlastRateSqFtPerHourOverride = rateOverride > 0 ? rateOverride : null
|
||||||
|
};
|
||||||
|
var rate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
|
||||||
|
return Json(new { rate });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
|
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetBlastSetups()
|
public async Task<IActionResult> GetBlastSetups()
|
||||||
@@ -2962,6 +3033,471 @@ public class CompanySettingsController : Controller
|
|||||||
return RedirectToAction(nameof(DeleteAccount));
|
return RedirectToAction(nameof(DeleteAccount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Custom Formula Item Templates ──────────────────────────────────────────
|
||||||
|
|
||||||
|
private bool AllowCustomFormulas() => HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
|
||||||
|
|
||||||
|
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetCustomItemTemplate(int id)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
var dto = _mapper.Map<CustomItemTemplateDto>(entity);
|
||||||
|
return Json(new { success = true, template = dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetCustomItemTemplates()
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId);
|
||||||
|
var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
|
||||||
|
return Json(new { success = true, templates = dtos });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Downloads all formula templates as a portable JSON backup file.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportCustomItemTemplates()
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Forbid();
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
|
||||||
|
|
||||||
|
// Parse FieldsJson into a real JsonElement so it is embedded as a proper JSON array
|
||||||
|
// in the export file rather than as an escaped string. This makes the file human-readable
|
||||||
|
// and avoids round-trip corruption when files are manually edited.
|
||||||
|
static System.Text.Json.JsonElement ParseFields(string? raw)
|
||||||
|
{
|
||||||
|
try { return System.Text.Json.JsonDocument.Parse(raw ?? "[]").RootElement.Clone(); }
|
||||||
|
catch { return System.Text.Json.JsonDocument.Parse("[]").RootElement.Clone(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
var export = new
|
||||||
|
{
|
||||||
|
exportedAt = DateTime.UtcNow,
|
||||||
|
version = 1,
|
||||||
|
templates = templates
|
||||||
|
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
t.Name,
|
||||||
|
t.Description,
|
||||||
|
t.OutputMode,
|
||||||
|
Fields = ParseFields(t.FieldsJson),
|
||||||
|
t.Formula,
|
||||||
|
t.DefaultRate,
|
||||||
|
t.RateLabel,
|
||||||
|
t.Notes,
|
||||||
|
t.DisplayOrder,
|
||||||
|
t.IsActive
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
var json = System.Text.Json.JsonSerializer.Serialize(export,
|
||||||
|
new System.Text.Json.JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
|
||||||
|
});
|
||||||
|
var filename = $"formula-templates-{DateTime.UtcNow:yyyyMMdd}.json";
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(json), "application/json", filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports formula templates from a JSON backup file produced by ExportCustomItemTemplates.
|
||||||
|
/// Templates whose name already exists in the company are skipped; all others are created.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ImportCustomItemTemplates(IFormFile file)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
if (file == null || file.Length == 0) return Json(new { success = false, message = "No file selected." });
|
||||||
|
if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Json(new { success = false, message = "File must be a .json export file." });
|
||||||
|
if (file.Length > 512 * 1024)
|
||||||
|
return Json(new { success = false, message = "File is too large (max 512 KB)." });
|
||||||
|
|
||||||
|
string json;
|
||||||
|
using (var reader = new System.IO.StreamReader(file.OpenReadStream()))
|
||||||
|
json = await reader.ReadToEndAsync();
|
||||||
|
|
||||||
|
System.Text.Json.JsonElement root;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
root = System.Text.Json.JsonDocument.Parse(json).RootElement;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = "Could not parse file — make sure it is a valid formula export." });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("templates", out var templatesEl) || templatesEl.ValueKind != System.Text.Json.JsonValueKind.Array)
|
||||||
|
return Json(new { success = false, message = "Invalid export format: missing \"templates\" array." });
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var existing = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
|
||||||
|
// Track names already in DB + names imported within this same file to prevent intra-file duplicates
|
||||||
|
var usedNames = existing.Select(t => t.Name.ToLowerInvariant()).ToHashSet();
|
||||||
|
|
||||||
|
int imported = 0, skipped = 0;
|
||||||
|
var skippedNames = new List<string>();
|
||||||
|
var errors = new List<string>();
|
||||||
|
|
||||||
|
foreach (var item in templatesEl.EnumerateArray())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var name = item.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) { errors.Add("Skipped one template with no name."); continue; }
|
||||||
|
|
||||||
|
if (usedNames.Contains(name.ToLowerInvariant()))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
skippedNames.Add(name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dto = new CreateCustomItemTemplateDto
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
|
||||||
|
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
|
||||||
|
// "fields" is a real JSON array in the export; GetRawText() reconstructs the string
|
||||||
|
FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]",
|
||||||
|
Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
|
||||||
|
DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null,
|
||||||
|
RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null,
|
||||||
|
Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null,
|
||||||
|
DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0,
|
||||||
|
IsActive = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
|
if (fieldError != null) { errors.Add($"\"{name}\": {fieldError}"); continue; }
|
||||||
|
|
||||||
|
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
|
||||||
|
if (formulaError != null) { errors.Add($"\"{name}\": formula error — {formulaError}"); continue; }
|
||||||
|
dto.Formula = normalizedFormula;
|
||||||
|
|
||||||
|
var entity = _mapper.Map<CustomItemTemplate>(dto);
|
||||||
|
entity.CompanyId = companyId;
|
||||||
|
entity.CreatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
|
||||||
|
|
||||||
|
usedNames.Add(name.ToLowerInvariant());
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
errors.Add($"Unexpected error on one template: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imported > 0)
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, imported, skipped, skippedNames, errors });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new formula template for the current company.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Json(new { success = false, message = "Invalid data." });
|
||||||
|
|
||||||
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
|
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
||||||
|
|
||||||
|
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
|
||||||
|
if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
|
||||||
|
dto.Formula = normalizedFormula;
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = _mapper.Map<CustomItemTemplate>(dto);
|
||||||
|
entity.CompanyId = companyId;
|
||||||
|
entity.CreatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, id = entity.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates an existing formula template owned by the current company.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Json(new { success = false, message = "Invalid data." });
|
||||||
|
|
||||||
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
|
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
||||||
|
|
||||||
|
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
|
||||||
|
if (formulaError != null) return Json(new { success = false, message = $"Formula error: {formulaError}" });
|
||||||
|
dto.Formula = normalizedFormula;
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
|
||||||
|
_mapper.Map(dto, entity);
|
||||||
|
entity.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// If this was imported from the library, mark it as modified so the share button appears
|
||||||
|
if (entity.SourceFormulaLibraryItemId.HasValue)
|
||||||
|
entity.IsModifiedFromSource = true;
|
||||||
|
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Soft-deletes a formula template owned by the current company.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
|
||||||
|
await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Community Library: share / unshare / status ───────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the community library status for a given template: whether it is published,
|
||||||
|
/// eligible to share, and where it was originally imported from if applicable.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> FormulaLibraryStatus(int templateId)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { canShare = false });
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var status = await _formulaLibraryService.GetTemplateLibraryStatusAsync(templateId, companyId);
|
||||||
|
return Json(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a company template to the community library (or re-publishes after unshare).
|
||||||
|
/// Only templates that are original creations or modified imports may be shared.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> ShareFormula([FromBody] PowderCoating.Application.DTOs.Company.ShareFormulaRequest request)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var libraryItemId = await _formulaLibraryService.ShareAsync(companyId, userId, request);
|
||||||
|
return Json(new { success = true, libraryItemId });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes a template from the community library. Existing company imports are unaffected.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> UnshareFormula(int libraryItemId)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
await _formulaLibraryService.UnshareAsync(libraryItemId, companyId);
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads a diagram image for a template to blob storage container
|
||||||
|
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
|
||||||
|
/// Returns the blob path for storage on the entity.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
|
||||||
|
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
|
||||||
|
if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
|
||||||
|
return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });
|
||||||
|
|
||||||
|
if (diagramFile.Length > 10 * 1024 * 1024)
|
||||||
|
return Json(new { success = false, message = "Image must be under 10 MB." });
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
|
||||||
|
var blobPath = $"{companyId}/{templateId}/diagram.{ext}";
|
||||||
|
|
||||||
|
using var stream = diagramFile.OpenReadStream();
|
||||||
|
var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
|
||||||
|
if (!ok)
|
||||||
|
return Json(new { success = false, message = err });
|
||||||
|
|
||||||
|
entity.DiagramImagePath = blobPath;
|
||||||
|
entity.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, diagramImagePath = blobPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a template diagram image from blob storage. The path is tenant-scoped
|
||||||
|
/// so cross-company access is prevented by checking CompanyId on the template.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> TemplateDiagram(int templateId)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||||
|
if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
|
||||||
|
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
|
||||||
|
return File(bytes, contentType ?? "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates a NCalc formula with the supplied variable values, automatically injecting
|
||||||
|
/// three read-only shop-rate variables sourced from the company's operating costs:
|
||||||
|
/// <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>.
|
||||||
|
/// User-supplied variables take precedence so the test panel can override them.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> EvaluateFormula([FromBody] EvaluateFormulaRequest req)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
|
||||||
|
|
||||||
|
// Inject shop-rate system variables; user-supplied values win if the same key appears in both.
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId != null)
|
||||||
|
{
|
||||||
|
var costs = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId.Value);
|
||||||
|
if (costs != null)
|
||||||
|
req = InjectShopRateVariables(req, costs);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = _formulaAiService.EvaluateFormula(req);
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges <c>standard_labor_rate</c>, <c>additional_coat_labor_pct</c>, and <c>markup_pct</c>
|
||||||
|
/// from <paramref name="costs"/> into the request's variable map without overwriting any key
|
||||||
|
/// the caller already set (so the test panel can still override these values explicitly).
|
||||||
|
/// </summary>
|
||||||
|
private static EvaluateFormulaRequest InjectShopRateVariables(
|
||||||
|
EvaluateFormulaRequest req, CompanyOperatingCosts costs)
|
||||||
|
{
|
||||||
|
var vars = System.Text.Json.JsonSerializer
|
||||||
|
.Deserialize<Dictionary<string, System.Text.Json.JsonElement>>(req.VariablesJson ?? "{}") ?? new();
|
||||||
|
|
||||||
|
void Inject(string key, decimal value)
|
||||||
|
{
|
||||||
|
if (!vars.ContainsKey(key))
|
||||||
|
vars[key] = System.Text.Json.JsonDocument.Parse(value.ToString("G")).RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject("standard_labor_rate", costs.StandardLaborRate);
|
||||||
|
Inject("additional_coat_labor_pct", costs.AdditionalCoatLaborPercent);
|
||||||
|
Inject("markup_pct", costs.GeneralMarkupPercentage);
|
||||||
|
|
||||||
|
return new EvaluateFormulaRequest
|
||||||
|
{
|
||||||
|
Formula = req.Formula,
|
||||||
|
VariablesJson = System.Text.Json.JsonSerializer.Serialize(vars)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calls Claude to generate a formula template from a natural-language description
|
||||||
|
/// and an optional diagram image uploaded in the same multipart form.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
|
||||||
|
{
|
||||||
|
if (!AllowCustomFormulas()) return Json(new { success = false, error = "Custom Formulas are not available on your current plan." });
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
return Json(new { success = false, error = "Description is required." });
|
||||||
|
|
||||||
|
byte[]? imageBytes = null;
|
||||||
|
string? imageContentType = null;
|
||||||
|
if (diagramImage is { Length: > 0 })
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await diagramImage.CopyToAsync(ms);
|
||||||
|
imageBytes = ms.ToArray();
|
||||||
|
imageContentType = diagramImage.ContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _formulaAiService.GenerateFormulaAsync(
|
||||||
|
new GenerateFormulaFromAiRequest { Description = description },
|
||||||
|
imageBytes,
|
||||||
|
imageContentType);
|
||||||
|
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates field variable names in a fieldsJson array against NCalc identifier rules:
|
||||||
|
/// must start with a letter, contain only letters/digits/underscores, and not use the
|
||||||
|
/// reserved name "rate" (which is auto-populated from the template's Default Rate).
|
||||||
|
/// Returns an error message string on failure, or null if all names are valid.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ValidateTemplateFields(string? fieldsJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fieldsJson)) return null;
|
||||||
|
|
||||||
|
List<System.Text.Json.JsonElement>? fields;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
|
||||||
|
}
|
||||||
|
catch { return "Invalid fields JSON."; }
|
||||||
|
|
||||||
|
if (fields == null) return null;
|
||||||
|
|
||||||
|
var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
return "All fields must have a variable name.";
|
||||||
|
if (name == "rate")
|
||||||
|
return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
|
||||||
|
if (!nameRegex.IsMatch(name))
|
||||||
|
return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
|
||||||
|
if (!seen.Add(name))
|
||||||
|
return $"Duplicate field name \"{name}\".";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||||
|
|||||||
@@ -293,6 +293,48 @@ public class DataPurgeController : Controller
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "Jobs":
|
case "Jobs":
|
||||||
|
// Collect IDs first so all FK cleanup targets the exact same set of jobs.
|
||||||
|
var purgingJobIds = await _db.Jobs.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff)
|
||||||
|
.Select(e => e.Id)
|
||||||
|
.ToListAsync();
|
||||||
|
if (purgingJobIds.Count > 0)
|
||||||
|
{
|
||||||
|
// Non-nullable FK children must be deleted before the parent row can go.
|
||||||
|
// ReworkRecord.JobId (Restrict), PowderUsageLog.JobId (NoAction),
|
||||||
|
// OvenBatchItem.JobId (NoAction) — cannot be nulled, so rows are removed.
|
||||||
|
await _db.ReworkRecords.IgnoreQueryFilters()
|
||||||
|
.Where(r => purgingJobIds.Contains(r.JobId))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
await _db.PowderUsageLogs.IgnoreQueryFilters()
|
||||||
|
.Where(l => purgingJobIds.Contains(l.JobId))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
await _db.OvenBatchItems.IgnoreQueryFilters()
|
||||||
|
.Where(i => purgingJobIds.Contains(i.JobId))
|
||||||
|
.ExecuteDeleteAsync();
|
||||||
|
|
||||||
|
// Nullable FKs with NO ACTION / RESTRICT — null them out so the DELETE
|
||||||
|
// does not violate the constraint. KioskSession and NotificationLog are
|
||||||
|
// excluded here because their FKs use SET NULL and the DB handles them.
|
||||||
|
await _db.Invoices.IgnoreQueryFilters()
|
||||||
|
.Where(i => i.JobId.HasValue && purgingJobIds.Contains(i.JobId.Value))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(i => i.JobId, (int?)null));
|
||||||
|
await _db.Deposits.IgnoreQueryFilters()
|
||||||
|
.Where(d => d.JobId.HasValue && purgingJobIds.Contains(d.JobId.Value))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(d => d.JobId, (int?)null));
|
||||||
|
await _db.Appointments.IgnoreQueryFilters()
|
||||||
|
.Where(a => a.JobId.HasValue && purgingJobIds.Contains(a.JobId.Value))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(a => a.JobId, (int?)null));
|
||||||
|
await _db.BillLineItems.IgnoreQueryFilters()
|
||||||
|
.Where(b => b.JobId.HasValue && purgingJobIds.Contains(b.JobId.Value))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(b => b.JobId, (int?)null));
|
||||||
|
await _db.Expenses.IgnoreQueryFilters()
|
||||||
|
.Where(e => e.JobId.HasValue && purgingJobIds.Contains(e.JobId.Value))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(e => e.JobId, (int?)null));
|
||||||
|
await _db.InventoryTransactions.IgnoreQueryFilters()
|
||||||
|
.Where(t => t.JobId.HasValue && purgingJobIds.Contains(t.JobId.Value))
|
||||||
|
.ExecuteUpdateAsync(s => s.SetProperty(t => t.JobId, (int?)null));
|
||||||
|
}
|
||||||
count = await _db.Jobs.IgnoreQueryFilters()
|
count = await _db.Jobs.IgnoreQueryFilters()
|
||||||
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Community formula library — browse published formulas from all companies and import
|
||||||
|
/// them into the current company's local template list.
|
||||||
|
/// </summary>
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CanViewData)]
|
||||||
|
public class FormulaLibraryController : Controller
|
||||||
|
{
|
||||||
|
private readonly IFormulaLibraryService _libraryService;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
private readonly IMapper _mapper;
|
||||||
|
private readonly IAzureBlobStorageService _blobStorage;
|
||||||
|
|
||||||
|
public FormulaLibraryController(
|
||||||
|
IFormulaLibraryService libraryService,
|
||||||
|
ITenantContext tenantContext,
|
||||||
|
IMapper mapper,
|
||||||
|
IAzureBlobStorageService blobStorage)
|
||||||
|
{
|
||||||
|
_libraryService = libraryService;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
|
_mapper = mapper;
|
||||||
|
_blobStorage = blobStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Browse the community library with optional search and filter params.</summary>
|
||||||
|
// GET: /FormulaLibrary
|
||||||
|
public async Task<IActionResult> Index(
|
||||||
|
string? search = null,
|
||||||
|
string? outputMode = null,
|
||||||
|
string? industryHint = null)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) return RedirectToAction("Index", "Home");
|
||||||
|
|
||||||
|
var items = await _libraryService.BrowseAsync(companyId.Value, search, outputMode, industryHint);
|
||||||
|
|
||||||
|
ViewBag.Search = search;
|
||||||
|
ViewBag.OutputMode = outputMode;
|
||||||
|
ViewBag.IndustryHint = industryHint;
|
||||||
|
ViewBag.TotalCount = items.Count();
|
||||||
|
|
||||||
|
return View(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns full detail JSON for the import preview modal.</summary>
|
||||||
|
// GET: /FormulaLibrary/Detail/5
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Detail(int id)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) return Json(new { error = "No company context." });
|
||||||
|
|
||||||
|
var detail = await _libraryService.GetDetailAsync(id, companyId.Value);
|
||||||
|
if (detail == null) return NotFound();
|
||||||
|
|
||||||
|
return Json(detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a formula diagram image by blob storage path. Used for library cards where the
|
||||||
|
/// diagram belongs to another company's template blob container.
|
||||||
|
/// </summary>
|
||||||
|
// GET: /FormulaLibrary/Diagram?path=...
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Diagram(string path)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(path)) return NotFound();
|
||||||
|
|
||||||
|
// Sanitize: path must not escape the blob container
|
||||||
|
if (path.Contains("..") || path.StartsWith("/") || path.StartsWith("\\"))
|
||||||
|
return BadRequest();
|
||||||
|
|
||||||
|
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", path);
|
||||||
|
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
|
||||||
|
return File(bytes, contentType ?? "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records or toggles a thumbs-up/down vote for the current company.
|
||||||
|
/// Returns updated counts so the UI can update without a page reload.
|
||||||
|
/// Companies cannot rate their own formulas; own-formula cards have no rating buttons.
|
||||||
|
/// </summary>
|
||||||
|
// POST: /FormulaLibrary/Rate
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> Rate([FromBody] RateFormulaRequest request)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) return Json(new { success = false, message = "No company context." });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (up, down, myVote) = await _libraryService.RateAsync(
|
||||||
|
request.LibraryItemId, companyId.Value, request.IsPositive);
|
||||||
|
return Json(new { success = true, thumbsUp = up, thumbsDown = down, myVote });
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException ex)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Imports a library entry as a new local template for the current company.</summary>
|
||||||
|
// POST: /FormulaLibrary/Import
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
|
public async Task<IActionResult> Import(int libraryItemId)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null)
|
||||||
|
return Json(new { success = false, message = "No company context." });
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||||
|
var templateId = await _libraryService.ImportAsync(libraryItemId, companyId.Value, userId);
|
||||||
|
return Json(new { success = true, templateId });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return Json(new { success = false, message = ex.Message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Body for the Rate endpoint.</summary>
|
||||||
|
public class RateFormulaRequest
|
||||||
|
{
|
||||||
|
public int LibraryItemId { get; set; }
|
||||||
|
/// <summary>True = thumbs up, false = thumbs down.</summary>
|
||||||
|
public bool IsPositive { get; set; }
|
||||||
|
}
|
||||||
@@ -125,5 +125,23 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the Custom Formula Item Templates help article explaining how to create NCalc formula
|
||||||
|
/// templates, use the AI generator, and add formula items to quotes and jobs.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult CustomFormulaTemplates()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the Timeclock help article explaining clock in/out, multi-segment days, the kiosk tablet
|
||||||
|
/// mode with PIN authentication, manager edit/delete tools, and the Attendance report.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult Timeclock()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,15 +56,16 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Displays the paginated inventory list with optional keyword search, category filter,
|
/// Displays the paginated inventory list with optional keyword search, category filter,
|
||||||
/// and a low-stock quick-filter. When lowStockOnly is active the default sort switches
|
/// color family filter, and a low-stock quick-filter. When lowStockOnly is active the
|
||||||
/// to QuantityOnHand ascending so the most depleted items surface immediately. Stats
|
/// default sort switches to QuantityOnHand ascending so the most depleted items surface
|
||||||
/// (total value, active count, low-stock count) are computed directly on the DbSet
|
/// immediately. Stats (total value, active count, low-stock count) are computed directly
|
||||||
/// using aggregate SQL to avoid loading all rows into memory.
|
/// on the DbSet using aggregate SQL to avoid loading all rows into memory.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(
|
public async Task<IActionResult> Index(
|
||||||
string? searchTerm,
|
string? searchTerm,
|
||||||
string? category,
|
string? category,
|
||||||
string? location,
|
string? location,
|
||||||
|
string? colorFamily,
|
||||||
string? sortColumn,
|
string? sortColumn,
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
bool lowStockOnly = false,
|
bool lowStockOnly = false,
|
||||||
@@ -88,64 +89,35 @@ public class InventoryController : Controller
|
|||||||
};
|
};
|
||||||
gridRequest.Validate();
|
gridRequest.Validate();
|
||||||
|
|
||||||
// Build filter — compose search, category, location, and low-stock predicates
|
|
||||||
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
|
||||||
|
|
||||||
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
||||||
var hasCategory = !string.IsNullOrWhiteSpace(category);
|
var hasCategory = !string.IsNullOrWhiteSpace(category);
|
||||||
var hasLocation = !string.IsNullOrWhiteSpace(location);
|
var hasLocation = !string.IsNullOrWhiteSpace(location);
|
||||||
|
var hasColorFamily = !string.IsNullOrWhiteSpace(colorFamily);
|
||||||
|
|
||||||
var search = searchTerm?.ToLower() ?? "";
|
var search = searchTerm?.ToLower() ?? "";
|
||||||
var cat = category ?? "";
|
var cat = category ?? "";
|
||||||
var loc = location ?? "";
|
var loc = location ?? "";
|
||||||
|
var colorFam = colorFamily ?? "";
|
||||||
|
|
||||||
if (lowStockOnly && hasSearch && hasLocation)
|
// Single composable predicate — EF Core evaluates the captured booleans as constants
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
// so inactive conditions fold to true and are omitted from the generated SQL WHERE clause.
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower())
|
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
||||||
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
if (hasSearch || hasCategory || hasLocation || hasColorFamily || lowStockOnly)
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
{
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
filter = i =>
|
||||||
else if (lowStockOnly && hasSearch)
|
(!lowStockOnly || (i.IsActive && i.QuantityOnHand <= i.ReorderPoint)) &&
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
(!hasSearch || (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
|
||||||
else if (lowStockOnly && hasLocation)
|
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
|
||||||
else if (lowStockOnly)
|
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
|
|
||||||
else if (hasSearch && hasCategory && hasLocation)
|
|
||||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))) &&
|
||||||
&& i.Category.ToLower() == cat.ToLower()
|
(!hasCategory || i.Category.ToLower() == cat.ToLower()) &&
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
(!hasLocation || (i.Location != null && i.Location.ToLower() == loc.ToLower())) &&
|
||||||
else if (hasSearch && hasCategory)
|
(!hasColorFamily || (i.ColorFamilies != null && (
|
||||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
i.ColorFamilies == colorFam ||
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
i.ColorFamilies.StartsWith(colorFam + ",") ||
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
i.ColorFamilies.EndsWith("," + colorFam) ||
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
i.ColorFamilies.Contains("," + colorFam + ","))));
|
||||||
&& i.Category.ToLower() == cat.ToLower();
|
}
|
||||||
else if (hasSearch && hasLocation)
|
|
||||||
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
|
||||||
else if (hasSearch)
|
|
||||||
filter = i => i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
|
|
||||||
else if (hasCategory && hasLocation)
|
|
||||||
filter = i => i.Category.ToLower() == cat.ToLower()
|
|
||||||
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
|
||||||
else if (hasCategory)
|
|
||||||
filter = i => i.Category.ToLower() == cat.ToLower();
|
|
||||||
else if (hasLocation)
|
|
||||||
filter = i => i.Location != null && i.Location.ToLower() == loc.ToLower();
|
|
||||||
|
|
||||||
// Build orderBy function
|
// Build orderBy function
|
||||||
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
||||||
@@ -179,6 +151,14 @@ public class InventoryController : Controller
|
|||||||
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||||
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
|
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
|
||||||
|
ViewBag.ColorFamilies = allItems
|
||||||
|
.Where(i => !string.IsNullOrEmpty(i.ColorFamilies))
|
||||||
|
.SelectMany(i => i.ColorFamilies!.Split(',', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.Select(f => f.Trim())
|
||||||
|
.Where(f => f.Length > 0)
|
||||||
|
.Distinct()
|
||||||
|
.OrderBy(f => f)
|
||||||
|
.ToList();
|
||||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||||
@@ -187,6 +167,7 @@ public class InventoryController : Controller
|
|||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
ViewBag.Category = category;
|
ViewBag.Category = category;
|
||||||
ViewBag.Location = location;
|
ViewBag.Location = location;
|
||||||
|
ViewBag.ColorFamily = colorFamily;
|
||||||
ViewBag.LowStockOnly = lowStockOnly;
|
ViewBag.LowStockOnly = lowStockOnly;
|
||||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||||
@@ -946,7 +927,10 @@ public class InventoryController : Controller
|
|||||||
if (!string.IsNullOrWhiteSpace(urlMfr))
|
if (!string.IsNullOrWhiteSpace(urlMfr))
|
||||||
{
|
{
|
||||||
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
|
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
|
||||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
// The scanned QR URL is always the authoritative product page link — it came
|
||||||
|
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
|
||||||
|
// whatever LookupAsync returned (which may be a scheme-less path from the template).
|
||||||
|
if (aiResult.Success)
|
||||||
aiResult.SpecPageUrl = qrUrl;
|
aiResult.SpecPageUrl = qrUrl;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -1238,9 +1222,13 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Find the default coating category to assign
|
// Find the default coating category to assign.
|
||||||
|
// Prefer the canonical "POWDER" category (CategoryCode == "POWDER") so catalog-sourced
|
||||||
|
// items always land in the right bucket regardless of how many IsCoating categories exist.
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var coatingCategory = categories
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
|
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
.Where(c => c.IsActive && c.IsCoating)
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
@@ -1492,8 +1480,20 @@ public class InventoryController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
|
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
|
||||||
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
|
.OrderBy(v => v.CompanyName).ToList();
|
||||||
|
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
|
||||||
|
|
||||||
|
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
|
||||||
|
var categoryVendorMap = new Dictionary<string, List<int>>();
|
||||||
|
foreach (var v in vendors)
|
||||||
|
foreach (var cat in v.Categories)
|
||||||
|
{
|
||||||
|
var key = cat.Id.ToString();
|
||||||
|
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
|
||||||
|
categoryVendorMap[key].Add(v.Id);
|
||||||
|
}
|
||||||
|
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
|
||||||
|
|
||||||
// Load categories from lookup table
|
// Load categories from lookup table
|
||||||
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
@@ -1620,11 +1620,12 @@ public class InventoryController : Controller
|
|||||||
/// Renders a print-optimised label for the inventory item containing the QR code,
|
/// Renders a print-optimised label for the inventory item containing the QR code,
|
||||||
/// item name, SKU, and colour. Designed to be printed directly from the browser.
|
/// item name, SKU, and colour. Designed to be printed directly from the browser.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Label(int? id)
|
public async Task<IActionResult> Label(int? id, bool embed = false)
|
||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
|
||||||
if (item == null) return NotFound();
|
if (item == null) return NotFound();
|
||||||
|
ViewBag.IsEmbed = embed;
|
||||||
return View(_mapper.Map<InventoryItemDto>(item));
|
return View(_mapper.Map<InventoryItemDto>(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1641,8 +1642,10 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var userId = _userManager.GetUserId(User);
|
var userId = _userManager.GetUserId(User);
|
||||||
|
|
||||||
|
var recentCutoff = DateTime.UtcNow.AddDays(-7);
|
||||||
|
|
||||||
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
var myJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
|
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus))
|
j => j.JobStatus))
|
||||||
@@ -1650,7 +1653,7 @@ public class InventoryController : Controller
|
|||||||
.Select(j => new ScanJobOption
|
.Select(j => new ScanJobOption
|
||||||
{
|
{
|
||||||
Id = j.Id,
|
Id = j.Id,
|
||||||
JobNumber = j.JobNumber,
|
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||||
CustomerName = j.Customer != null
|
CustomerName = j.Customer != null
|
||||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||||
: "No Customer"
|
: "No Customer"
|
||||||
@@ -1659,7 +1662,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
|
||||||
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
|
j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && !myJobIds.Contains(j.Id),
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus))
|
j => j.JobStatus))
|
||||||
@@ -1668,7 +1671,7 @@ public class InventoryController : Controller
|
|||||||
.Select(j => new ScanJobOption
|
.Select(j => new ScanJobOption
|
||||||
{
|
{
|
||||||
Id = j.Id,
|
Id = j.Id,
|
||||||
JobNumber = j.JobNumber,
|
JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
|
||||||
CustomerName = j.Customer != null
|
CustomerName = j.Customer != null
|
||||||
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
|
||||||
: "No Customer"
|
: "No Customer"
|
||||||
@@ -1685,9 +1688,64 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records powder usage logged via the mobile scan page. Creates a JobUsage
|
/// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
|
||||||
/// InventoryTransaction (and PowderUsageLog) when a job is selected, or an
|
/// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
|
||||||
/// Adjustment transaction when logging without a job. Updates QuantityOnHand.
|
/// </summary>
|
||||||
|
private async Task<InventoryUsageResult> RecordInventoryUsageAsync(
|
||||||
|
int inventoryItemId, int? jobId, decimal quantityUsed,
|
||||||
|
InventoryTransactionType transactionType, string? notes)
|
||||||
|
{
|
||||||
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
||||||
|
if (item == null)
|
||||||
|
return new InventoryUsageResult(false, "Inventory item not found.", 0, "", "");
|
||||||
|
|
||||||
|
string? reference = null;
|
||||||
|
if (jobId.HasValue)
|
||||||
|
{
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
|
||||||
|
reference = job != null ? $"Job {job.JobNumber}" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.QuantityOnHand -= quantityUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var txn = new InventoryTransaction
|
||||||
|
{
|
||||||
|
InventoryItemId = item.Id,
|
||||||
|
TransactionType = transactionType,
|
||||||
|
Quantity = -quantityUsed,
|
||||||
|
UnitCost = item.UnitCost,
|
||||||
|
TotalCost = quantityUsed * item.UnitCost,
|
||||||
|
TransactionDate = DateTime.UtcNow,
|
||||||
|
BalanceAfter = item.QuantityOnHand,
|
||||||
|
JobId = jobId,
|
||||||
|
Reference = reference,
|
||||||
|
Notes = notes?.Trim(),
|
||||||
|
CompanyId = item.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||||
|
{
|
||||||
|
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
||||||
|
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||||
|
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new InventoryUsageResult(
|
||||||
|
true,
|
||||||
|
$"Logged {quantityUsed:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.",
|
||||||
|
item.QuantityOnHand,
|
||||||
|
item.UnitOfMeasure,
|
||||||
|
item.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records powder usage from the mobile scan page. Resolves the used quantity
|
||||||
|
/// (caller already converts "remaining weight" to delta before posting) and redirects to ScanSuccess.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
@@ -1696,55 +1754,26 @@ public class InventoryController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
|
|
||||||
if (item == null) return NotFound();
|
|
||||||
|
|
||||||
if (quantity <= 0)
|
if (quantity <= 0)
|
||||||
{
|
{
|
||||||
TempData["ScanError"] = "Quantity must be greater than zero.";
|
TempData["ScanError"] = "Quantity must be greater than zero.";
|
||||||
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = _userManager.GetUserId(User) ?? string.Empty;
|
var result = await RecordInventoryUsageAsync(
|
||||||
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only
|
inventoryItemId, jobId, quantity,
|
||||||
var txnType = InventoryTransactionType.JobUsage;
|
InventoryTransactionType.JobUsage, notes);
|
||||||
|
|
||||||
item.QuantityOnHand -= quantity;
|
if (!result.Success)
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
|
||||||
|
|
||||||
var txn = new InventoryTransaction
|
|
||||||
{
|
{
|
||||||
InventoryItemId = item.Id,
|
TempData["ScanError"] = result.Message;
|
||||||
TransactionType = txnType,
|
return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
|
||||||
Quantity = -quantity,
|
|
||||||
UnitCost = item.UnitCost,
|
|
||||||
TotalCost = quantity * item.UnitCost,
|
|
||||||
TransactionDate = DateTime.UtcNow,
|
|
||||||
BalanceAfter = item.QuantityOnHand,
|
|
||||||
JobId = jobId,
|
|
||||||
Reference = jobId.HasValue ? $"Job #{jobId}" : null,
|
|
||||||
Notes = notes?.Trim()
|
|
||||||
};
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
|
||||||
await _unitOfWork.SaveChangesAsync();
|
|
||||||
|
|
||||||
// GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item
|
|
||||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
|
||||||
{
|
|
||||||
var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
|
||||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
|
||||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging
|
TempData["ScanSuccess"] = result.Message;
|
||||||
// doesn't have that context, so we rely on the InventoryTransaction alone
|
|
||||||
// for the audit trail. Coat-level PowderUsageLogs are created by the job workflow.
|
|
||||||
|
|
||||||
TempData["ScanSuccess"] = $"Logged {quantity:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.";
|
|
||||||
TempData["ScanItemId"] = inventoryItemId.ToString();
|
TempData["ScanItemId"] = inventoryItemId.ToString();
|
||||||
TempData["ScanJobId"] = jobId?.ToString();
|
TempData["ScanJobId"] = jobId?.ToString();
|
||||||
TempData["ScanItemName"] = item.Name;
|
TempData["ScanItemName"] = result.ItemName;
|
||||||
return RedirectToAction(nameof(ScanSuccess));
|
return RedirectToAction(nameof(ScanSuccess));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1755,6 +1784,43 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records manual material usage from the job details modal. Accepts JSON, resolves
|
||||||
|
/// the amount used (caller sends the already-computed used quantity), and returns JSON
|
||||||
|
/// so the modal can close and refresh inline.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (req.QuantityUsed <= 0)
|
||||||
|
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
||||||
|
|
||||||
|
var txnType = req.TransactionType == "Waste"
|
||||||
|
? InventoryTransactionType.Waste
|
||||||
|
: InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
var result = await RecordInventoryUsageAsync(
|
||||||
|
req.InventoryItemId, req.JobId, req.QuantityUsed, txnType, req.Notes);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
message = result.Message,
|
||||||
|
newBalance = result.NewBalance,
|
||||||
|
unitOfMeasure = result.UnitOfMeasure,
|
||||||
|
itemName = result.ItemName
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
|
||||||
|
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
|
/// Success screen shown after a usage log is saved. Offers "Log Another Item for
|
||||||
/// This Job" and "Done" options.
|
/// This Job" and "Done" options.
|
||||||
@@ -2002,7 +2068,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
|
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active
|
||||||
/// jobs so the edit modal can be pre-populated without a full page reload.
|
/// jobs (plus the currently assigned job even if terminal) for the edit modal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetUsageForEdit(int id)
|
public async Task<IActionResult> GetUsageForEdit(int id)
|
||||||
@@ -2033,10 +2099,27 @@ public class InventoryController : Controller
|
|||||||
})
|
})
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// If the assigned job has terminal status it won't appear in the active list; insert it at the top
|
||||||
|
// so the dropdown pre-selects correctly and the user can see the existing job assignment.
|
||||||
|
if (txn.JobId.HasValue && jobs.All(j => j.Id != txn.JobId.Value))
|
||||||
|
{
|
||||||
|
var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(txn.JobId.Value, false, j => j.Customer);
|
||||||
|
if (assignedJob != null)
|
||||||
|
jobs.Insert(0, new ScanJobOption
|
||||||
|
{
|
||||||
|
Id = assignedJob.Id,
|
||||||
|
JobNumber = assignedJob.JobNumber,
|
||||||
|
CustomerName = assignedJob.Customer != null
|
||||||
|
? (assignedJob.Customer.CompanyName ?? $"{assignedJob.Customer.ContactFirstName} {assignedJob.Customer.ContactLastName}".Trim())
|
||||||
|
: "No Customer"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
transactionId = txn.Id,
|
transactionId = txn.Id,
|
||||||
jobId = txn.JobId,
|
jobId = txn.JobId,
|
||||||
|
quantity = Math.Abs(txn.Quantity),
|
||||||
notes = txn.Notes,
|
notes = txn.Notes,
|
||||||
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
|
||||||
itemName = txn.InventoryItem?.Name,
|
itemName = txn.InventoryItem?.Name,
|
||||||
@@ -2045,14 +2128,15 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date.
|
/// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
|
||||||
/// Quantity and balance are not changed.
|
/// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
|
||||||
|
/// ledger balance remains consistent.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate)
|
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate, decimal? quantity)
|
||||||
{
|
{
|
||||||
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id);
|
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.InventoryItem);
|
||||||
if (txn == null) return NotFound();
|
if (txn == null) return NotFound();
|
||||||
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
if (txn.TransactionType != InventoryTransactionType.JobUsage
|
||||||
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
&& txn.TransactionType != InventoryTransactionType.Adjustment)
|
||||||
@@ -2074,6 +2158,28 @@ public class InventoryController : Controller
|
|||||||
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
|
||||||
txn.TransactionType = InventoryTransactionType.JobUsage;
|
txn.TransactionType = InventoryTransactionType.JobUsage;
|
||||||
|
|
||||||
|
// Adjust inventory when the logged quantity is changed.
|
||||||
|
// txn.Quantity is stored as a negative number for usage (e.g. -3.5 for 3.5 lbs used).
|
||||||
|
if (quantity.HasValue && quantity.Value > 0)
|
||||||
|
{
|
||||||
|
var oldUsed = Math.Abs(txn.Quantity);
|
||||||
|
var newUsed = quantity.Value;
|
||||||
|
if (oldUsed != newUsed)
|
||||||
|
{
|
||||||
|
var item = txn.InventoryItem ?? await _unitOfWork.InventoryItems.GetByIdAsync(txn.InventoryItemId);
|
||||||
|
if (item != null)
|
||||||
|
{
|
||||||
|
// Positive delta means less was actually used → restore the difference to inventory.
|
||||||
|
item.QuantityOnHand += oldUsed - newUsed;
|
||||||
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
txn.BalanceAfter = item.QuantityOnHand;
|
||||||
|
}
|
||||||
|
txn.Quantity = -newUsed;
|
||||||
|
txn.TotalCost = newUsed * txn.UnitCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
txn.Notes = notes?.Trim();
|
txn.Notes = notes?.Trim();
|
||||||
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
|
||||||
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
|
||||||
@@ -2093,3 +2199,21 @@ public class ScanJobOption
|
|||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Result returned by RecordInventoryUsageAsync.</summary>
|
||||||
|
public record InventoryUsageResult(
|
||||||
|
bool Success,
|
||||||
|
string Message,
|
||||||
|
decimal NewBalance,
|
||||||
|
string UnitOfMeasure,
|
||||||
|
string ItemName);
|
||||||
|
|
||||||
|
/// <summary>JSON body for the LogMaterial endpoint (job details modal).</summary>
|
||||||
|
public class LogMaterialRequest
|
||||||
|
{
|
||||||
|
public int JobId { get; set; }
|
||||||
|
public int InventoryItemId { get; set; }
|
||||||
|
public decimal QuantityUsed { get; set; }
|
||||||
|
public string TransactionType { get; set; } = "JobUsage";
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
|
|||||||
dto.JobId = job.Id;
|
dto.JobId = job.Id;
|
||||||
dto.CustomerId = job.CustomerId;
|
dto.CustomerId = job.CustomerId;
|
||||||
dto.CustomerPO = job.CustomerPO;
|
dto.CustomerPO = job.CustomerPO;
|
||||||
|
dto.ProjectName = job.ProjectName;
|
||||||
|
|
||||||
// Resolve catalog item revenue accounts for pre-population
|
// Resolve catalog item revenue accounts for pre-population
|
||||||
var catalogItemIds = job.JobItems
|
var catalogItemIds = job.JobItems
|
||||||
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
|
|||||||
InternalNotes = dto.InternalNotes,
|
InternalNotes = dto.InternalNotes,
|
||||||
Terms = dto.Terms,
|
Terms = dto.Terms,
|
||||||
CustomerPO = dto.CustomerPO,
|
CustomerPO = dto.CustomerPO,
|
||||||
|
ProjectName = dto.ProjectName,
|
||||||
CompanyId = currentUser.CompanyId,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
CreatedBy = currentUser.Email
|
CreatedBy = currentUser.Email
|
||||||
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
|
|||||||
InternalNotes = invoice.InternalNotes,
|
InternalNotes = invoice.InternalNotes,
|
||||||
Terms = invoice.Terms,
|
Terms = invoice.Terms,
|
||||||
CustomerPO = invoice.CustomerPO,
|
CustomerPO = invoice.CustomerPO,
|
||||||
|
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
|
||||||
InvoiceItems = invoice.InvoiceItems
|
InvoiceItems = invoice.InvoiceItems
|
||||||
.Where(i => !i.IsDeleted)
|
.Where(i => !i.IsDeleted)
|
||||||
.OrderBy(i => i.DisplayOrder)
|
.OrderBy(i => i.DisplayOrder)
|
||||||
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
|
|||||||
invoice.InternalNotes = dto.InternalNotes;
|
invoice.InternalNotes = dto.InternalNotes;
|
||||||
invoice.Terms = dto.Terms;
|
invoice.Terms = dto.Terms;
|
||||||
invoice.CustomerPO = dto.CustomerPO;
|
invoice.CustomerPO = dto.CustomerPO;
|
||||||
|
invoice.ProjectName = dto.ProjectName;
|
||||||
invoice.UpdatedAt = DateTime.UtcNow;
|
invoice.UpdatedAt = DateTime.UtcNow;
|
||||||
invoice.UpdatedBy = currentUser?.Email;
|
invoice.UpdatedBy = currentUser?.Email;
|
||||||
|
|
||||||
|
|||||||
@@ -492,6 +492,9 @@ public class JobsController : Controller
|
|||||||
isLaborItem = ji.IsLaborItem,
|
isLaborItem = ji.IsLaborItem,
|
||||||
isSalesItem = ji.IsSalesItem,
|
isSalesItem = ji.IsSalesItem,
|
||||||
isAiItem = ji.IsAiItem,
|
isAiItem = ji.IsAiItem,
|
||||||
|
isCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
customItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
formulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
sku = ji.Sku,
|
sku = ji.Sku,
|
||||||
requiresSandblasting = ji.RequiresSandblasting,
|
requiresSandblasting = ji.RequiresSandblasting,
|
||||||
requiresMasking = ji.RequiresMasking,
|
requiresMasking = ji.RequiresMasking,
|
||||||
@@ -1173,6 +1176,26 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option B: auto-add Custom Powder Order item on first save if not already present
|
||||||
|
var allCreateItems = dto.JobItems.ToList();
|
||||||
|
if (!allCreateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
|
||||||
|
{
|
||||||
|
var powderDto = await BuildCustomPowderOrderDto(allCreateItems);
|
||||||
|
if (powderDto != null)
|
||||||
|
{
|
||||||
|
var pp = new QuoteItemPricingResult
|
||||||
|
{
|
||||||
|
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
|
||||||
|
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
|
||||||
|
LaborCost = 0, EquipmentCost = 0
|
||||||
|
};
|
||||||
|
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, companyId, pp, DateTime.UtcNow);
|
||||||
|
await _unitOfWork.JobItems.AddAsync(pi);
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
allCreateItems.Add(powderDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Recalculate total from wizard items
|
// Recalculate total from wizard items
|
||||||
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
var createCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
decimal? createOvenRate = null;
|
decimal? createOvenRate = null;
|
||||||
@@ -1183,7 +1206,7 @@ public class JobsController : Controller
|
|||||||
createOvenRate = createOven.CostPerHour;
|
createOvenRate = createOven.CostPerHour;
|
||||||
}
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
allCreateItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
||||||
|
|
||||||
@@ -1282,6 +1305,9 @@ public class JobsController : Controller
|
|||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
IsAiItem = ji.IsAiItem,
|
IsAiItem = ji.IsAiItem,
|
||||||
|
IsCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -1394,6 +1420,26 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option B: auto-add Custom Powder Order item on first save if not already present
|
||||||
|
var allEditItems = dto.JobItems.ToList();
|
||||||
|
if (!allEditItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
|
||||||
|
{
|
||||||
|
var powderDto = await BuildCustomPowderOrderDto(allEditItems);
|
||||||
|
if (powderDto != null)
|
||||||
|
{
|
||||||
|
var pp = new QuoteItemPricingResult
|
||||||
|
{
|
||||||
|
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
|
||||||
|
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
|
||||||
|
LaborCost = 0, EquipmentCost = 0
|
||||||
|
};
|
||||||
|
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, id, companyId, pp, DateTime.UtcNow);
|
||||||
|
await _unitOfWork.JobItems.AddAsync(pi);
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
allEditItems.Add(powderDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Now load and update the job itself
|
// Now load and update the job itself
|
||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(id);
|
var job = await _unitOfWork.Jobs.GetByIdAsync(id);
|
||||||
if (job == null)
|
if (job == null)
|
||||||
@@ -1647,7 +1693,7 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Recalculate FinalPrice from wizard items
|
// Recalculate FinalPrice from wizard items
|
||||||
if (dto.JobItems.Any())
|
if (allEditItems.Any())
|
||||||
{
|
{
|
||||||
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
var editCosts = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
decimal? editOvenRate = null;
|
decimal? editOvenRate = null;
|
||||||
@@ -1658,7 +1704,7 @@ public class JobsController : Controller
|
|||||||
editOvenRate = editOven.CostPerHour;
|
editOvenRate = editOven.CostPerHour;
|
||||||
}
|
}
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
allEditItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
||||||
job.FinalPrice = totals.Total;
|
job.FinalPrice = totals.Total;
|
||||||
@@ -1852,6 +1898,33 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
|
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
|
||||||
|
|
||||||
|
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
|
||||||
|
if (allowFormulas)
|
||||||
|
{
|
||||||
|
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId && t.IsActive);
|
||||||
|
ViewBag.CustomFormulaTemplates = formulaTemplates
|
||||||
|
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
id = t.Id,
|
||||||
|
name = t.Name,
|
||||||
|
description = t.Description,
|
||||||
|
outputMode = t.OutputMode,
|
||||||
|
fieldsJson = t.FieldsJson,
|
||||||
|
formula = t.Formula,
|
||||||
|
defaultRate = t.DefaultRate,
|
||||||
|
rateLabel = t.RateLabel,
|
||||||
|
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
|
||||||
|
? (string?)null
|
||||||
|
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewBag.CustomFormulaTemplates = new List<object>();
|
||||||
|
}
|
||||||
|
|
||||||
await PopulateDropdowns();
|
await PopulateDropdowns();
|
||||||
await PopulatePrepServicesAsync(companyId);
|
await PopulatePrepServicesAsync(companyId);
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
@@ -2407,6 +2480,28 @@ public class JobsController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a rework job reaches a terminal status, close out the linked ReworkRecord
|
||||||
|
// on the original job so the shop doesn't have to do it manually.
|
||||||
|
// Cancelled → WrittenOff; any other terminal → Resolved.
|
||||||
|
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
|
||||||
|
{
|
||||||
|
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
||||||
|
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
|
||||||
|
foreach (var rr in linkedRecords)
|
||||||
|
{
|
||||||
|
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
|
||||||
|
continue;
|
||||||
|
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
|
||||||
|
? ReworkStatus.WrittenOff
|
||||||
|
: ReworkStatus.Resolved;
|
||||||
|
rr.ResolvedDate ??= DateTime.UtcNow;
|
||||||
|
rr.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
|
||||||
|
}
|
||||||
|
if (linkedRecords.Any())
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Notify customer on status change (only if user opted in)
|
// Notify customer on status change (only if user opted in)
|
||||||
if (request.SendEmail && newStatus != null)
|
if (request.SendEmail && newStatus != null)
|
||||||
{
|
{
|
||||||
@@ -2962,6 +3057,9 @@ public class JobsController : Controller
|
|||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
IsAiItem = ji.IsAiItem,
|
IsAiItem = ji.IsAiItem,
|
||||||
|
IsCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -3077,6 +3175,26 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option B: auto-add Custom Powder Order item on first save if not already present
|
||||||
|
var allUpdateItems = model.JobItems.ToList();
|
||||||
|
if (!allUpdateItems.Any(d => d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true))
|
||||||
|
{
|
||||||
|
var powderDto = await BuildCustomPowderOrderDto(allUpdateItems);
|
||||||
|
if (powderDto != null)
|
||||||
|
{
|
||||||
|
var pp = new QuoteItemPricingResult
|
||||||
|
{
|
||||||
|
UnitPrice = powderDto.ManualUnitPrice!.Value, TotalPrice = powderDto.ManualUnitPrice!.Value,
|
||||||
|
ItemSubtotal = powderDto.ManualUnitPrice!.Value, MaterialCost = powderDto.ManualUnitPrice!.Value,
|
||||||
|
LaborCost = 0, EquipmentCost = 0
|
||||||
|
};
|
||||||
|
var pi = _jobItemAssemblyService.CreateJobItem(powderDto, job.Id, currentUser.CompanyId, pp, DateTime.UtcNow);
|
||||||
|
await _unitOfWork.JobItems.AddAsync(pi);
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
allUpdateItems.Add(powderDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Calculate full total (overhead, margins, tax) matching what Details shows
|
// Calculate full total (overhead, margins, tax) matching what Details shows
|
||||||
decimal? ovenRateOverride = null;
|
decimal? ovenRateOverride = null;
|
||||||
if (job.OvenCostId.HasValue)
|
if (job.OvenCostId.HasValue)
|
||||||
@@ -3087,7 +3205,7 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
var updateCosts = await _pricingService.GetOperatingCostsAsync(currentUser.CompanyId);
|
||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
model.JobItems, currentUser.CompanyId, job.CustomerId,
|
allUpdateItems, currentUser.CompanyId, job.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
await GetEffectiveTaxPercentAsync(job.CustomerId, updateCosts?.TaxPercent ?? 0m),
|
||||||
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
job.DiscountType.ToString(), job.DiscountValue, job.IsRushJob,
|
||||||
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
|
ovenRateOverride, job.OvenBatches, job.OvenCycleMinutes);
|
||||||
@@ -3154,6 +3272,9 @@ public class JobsController : Controller
|
|||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
IsSalesItem = ji.IsSalesItem,
|
IsSalesItem = ji.IsSalesItem,
|
||||||
IsAiItem = ji.IsAiItem,
|
IsAiItem = ji.IsAiItem,
|
||||||
|
IsCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||||
IncludePrepCost = ji.IncludePrepCost,
|
IncludePrepCost = ji.IncludePrepCost,
|
||||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||||
@@ -3287,6 +3408,21 @@ public class JobsController : Controller
|
|||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.UseMetric = useMetric;
|
ViewBag.UseMetric = useMetric;
|
||||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||||
|
|
||||||
|
var allowFormulas2 = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
|
||||||
|
if (allowFormulas2)
|
||||||
|
{
|
||||||
|
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
|
||||||
|
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
|
||||||
|
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
|
||||||
|
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
|
||||||
|
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewBag.CustomFormulaTemplates = new List<object>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3322,6 +3458,66 @@ public class JobsController : Controller
|
|||||||
return companyDefaultRate;
|
return companyDefaultRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a "Custom Powder Order" DTO by aggregating all powder-to-order costs across the
|
||||||
|
/// submitted items. Returns null when no qualifying coats are present.
|
||||||
|
///
|
||||||
|
/// Two coat types qualify:
|
||||||
|
/// - Custom powder: no InventoryItemId, PowderToOrder > 0, PowderCostPerLb > 0
|
||||||
|
/// - Incoming powder: InventoryItemId set, inventoryItem.IsIncoming == true, PowderToOrder > 0
|
||||||
|
/// (PowderCostPerLb is null for incoming powder — cost comes from inventoryItem.UnitCost)
|
||||||
|
/// </summary>
|
||||||
|
private async Task<CreateQuoteItemDto?> BuildCustomPowderOrderDto(IEnumerable<CreateQuoteItemDto> itemDtos)
|
||||||
|
{
|
||||||
|
var colorNames = new List<string>();
|
||||||
|
decimal totalCost = 0m;
|
||||||
|
|
||||||
|
foreach (var dto in itemDtos)
|
||||||
|
{
|
||||||
|
if (dto.Coats == null) continue;
|
||||||
|
foreach (var coat in dto.Coats)
|
||||||
|
{
|
||||||
|
if (!coat.InventoryItemId.HasValue &&
|
||||||
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||||
|
{
|
||||||
|
// Custom powder: no inventory link, user entered cost per lb manually
|
||||||
|
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||||
|
colorNames.Add(coat.ColorName);
|
||||||
|
}
|
||||||
|
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
|
{
|
||||||
|
// Incoming powder: catalog-selected; PowderCostPerLb was cleared after incoming
|
||||||
|
// inventory item was created, so cost comes from inventoryItem.UnitCost
|
||||||
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||||
|
if (invItem?.IsIncoming == true)
|
||||||
|
{
|
||||||
|
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
||||||
|
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
colorNames.Add(colorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCost <= 0) return null;
|
||||||
|
|
||||||
|
var uniqueColors = colorNames.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var description = uniqueColors.Any()
|
||||||
|
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
||||||
|
: "Custom Powder Order";
|
||||||
|
|
||||||
|
return new CreateQuoteItemDto
|
||||||
|
{
|
||||||
|
Description = description,
|
||||||
|
Quantity = 1,
|
||||||
|
IsGenericItem = true,
|
||||||
|
ManualUnitPrice = totalCost
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
|
private static QuotePricingBreakdownDto BuildPricingSnapshotDto(QuotePricingResult pr) =>
|
||||||
new QuotePricingBreakdownDto
|
new QuotePricingBreakdownDto
|
||||||
{
|
{
|
||||||
@@ -3528,10 +3724,13 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a rework event against a job item (e.g. defect found during QC).
|
/// Records a rework event against a job. Optionally creates a linked rework job so the
|
||||||
/// Automatically creates a new linked rework Job so the repair work can be tracked
|
/// repair can flow through the full shop lifecycle. When creating a rework job:
|
||||||
/// through the same job lifecycle. The rework job inherits the original job's customer,
|
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1)
|
||||||
/// oven, and items so the shop has a complete specification to work from.
|
/// - Only items selected by the user are copied (partial rework support)
|
||||||
|
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
|
||||||
|
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
|
||||||
|
/// - Job starts at the first non-Pending status in the company's workflow
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
||||||
@@ -3540,66 +3739,41 @@ public class JobsController : Controller
|
|||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var companyId = job.CompanyId;
|
var companyId = job.CompanyId;
|
||||||
|
Job? reworkJob = null;
|
||||||
|
|
||||||
// Generate rework job number
|
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue)
|
||||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
|
||||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
|
||||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
|
||||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
|
||||||
|
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
|
||||||
var year = DateTime.Now.ToString("yy");
|
|
||||||
var month = DateTime.Now.ToString("MM");
|
|
||||||
var prefix = $"JOB-{year}{month}-";
|
|
||||||
var maxNum = allJobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
|
||||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
|
||||||
.DefaultIfEmpty(0).Max();
|
|
||||||
|
|
||||||
var reworkJob = pendingStatus != null ? new Job
|
|
||||||
{
|
{
|
||||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
var typeLabel = dto.ReworkType switch
|
||||||
CustomerId = job.CustomerId,
|
|
||||||
Description = $"REWORK: {job.Description}",
|
|
||||||
JobStatusId = pendingStatus.Id,
|
|
||||||
JobPriorityId = normalPriority.Id,
|
|
||||||
IsReworkJob = true,
|
|
||||||
OriginalJobId = job.Id,
|
|
||||||
SpecialInstructions = $"Rework of {job.JobNumber}.",
|
|
||||||
CompanyId = companyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
if (reworkJob != null)
|
|
||||||
{
|
{
|
||||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
ReworkType.InternalDefect => "Internal Defect",
|
||||||
await _unitOfWork.CompleteAsync();
|
ReworkType.CustomerWarranty => "Customer Warranty",
|
||||||
|
ReworkType.CustomerDamage => "Customer Damage",
|
||||||
// Copy items: specific item if flagged, otherwise all items
|
_ => dto.ReworkType.ToString()
|
||||||
var itemsToCopy = dto.JobItemId.HasValue
|
};
|
||||||
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
|
var reasonLabel = dto.Reason switch
|
||||||
: job.JobItems.ToList();
|
|
||||||
|
|
||||||
foreach (var item in itemsToCopy)
|
|
||||||
{
|
{
|
||||||
var createdAtUtc = DateTime.UtcNow;
|
ReworkReason.AdhesionFailure => "Adhesion Failure",
|
||||||
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
ReworkReason.Contamination => "Contamination",
|
||||||
|
ReworkReason.ColorMismatch => "Color Mismatch",
|
||||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
ReworkReason.RunsSags => "Runs / Sags",
|
||||||
await _unitOfWork.CompleteAsync();
|
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
|
||||||
|
ReworkReason.OvenIssue => "Oven Issue",
|
||||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
|
||||||
|
ReworkReason.HandlingDamage => "Handling Damage",
|
||||||
|
_ => "Other"
|
||||||
|
};
|
||||||
|
var pricingLabel = dto.ReworkPricingType.Value switch
|
||||||
{
|
{
|
||||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||||
}
|
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||||
|
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
|
||||||
|
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
|
||||||
|
|
||||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
var currentUserId = _userManager.GetUserId(User);
|
||||||
{
|
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId);
|
||||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var record = new ReworkRecord
|
var record = new ReworkRecord
|
||||||
@@ -3615,6 +3789,7 @@ public class JobsController : Controller
|
|||||||
EstimatedReworkCost = dto.EstimatedReworkCost,
|
EstimatedReworkCost = dto.EstimatedReworkCost,
|
||||||
IsBillableToCustomer = dto.IsBillableToCustomer,
|
IsBillableToCustomer = dto.IsBillableToCustomer,
|
||||||
BillingNotes = dto.BillingNotes,
|
BillingNotes = dto.BillingNotes,
|
||||||
|
ReworkPricingType = dto.ReworkPricingType,
|
||||||
ReworkJobId = reworkJob?.Id,
|
ReworkJobId = reworkJob?.Id,
|
||||||
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
@@ -3624,11 +3799,147 @@ public class JobsController : Controller
|
|||||||
await _unitOfWork.ReworkRecords.AddAsync(record);
|
await _unitOfWork.ReworkRecords.AddAsync(record);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Reload with navigation for response
|
|
||||||
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
|
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
|
||||||
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
|
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a linked rework Job from an existing rework record that was saved without one.
|
||||||
|
/// Uses sub-number format and applies the specified pricing attribution.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
||||||
|
{
|
||||||
|
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
||||||
|
if (reworkRecord == null) return NotFound();
|
||||||
|
|
||||||
|
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
|
||||||
|
if (originalJob == null) return NotFound();
|
||||||
|
|
||||||
|
var companyId = originalJob.CompanyId;
|
||||||
|
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
|
||||||
|
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
|
||||||
|
|
||||||
|
var pricingLabel = pricingType switch
|
||||||
|
{
|
||||||
|
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||||
|
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||||
|
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
|
||||||
|
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
|
||||||
|
var currentUserId = _userManager.GetUserId(User);
|
||||||
|
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
|
||||||
|
|
||||||
|
reworkRecord.ReworkJobId = reworkJob.Id;
|
||||||
|
reworkRecord.ReworkPricingType = pricingType;
|
||||||
|
reworkRecord.Status = ReworkStatus.InProgress;
|
||||||
|
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
|
||||||
|
/// copies the specified items (with coats and prep services), applies pricing attribution,
|
||||||
|
/// sets descriptive job description from the rework record data, and auto-records intake
|
||||||
|
/// (parts are already on hand when rework is logged).
|
||||||
|
/// Called by both AddReworkRecord and CreateReworkJob.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Job> BuildReworkJobAsync(
|
||||||
|
Job originalJob,
|
||||||
|
List<int> itemIds,
|
||||||
|
ReworkPricingType pricingType,
|
||||||
|
int companyId,
|
||||||
|
string reworkDescription,
|
||||||
|
string? checkedByUserId)
|
||||||
|
{
|
||||||
|
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||||
|
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||||
|
|
||||||
|
// First non-Pending status by workflow order
|
||||||
|
var firstActiveStatus = statuses
|
||||||
|
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
|
||||||
|
.OrderBy(s => s.DisplayOrder)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||||
|
|
||||||
|
// Sub-number: {parentJobNumber}-R{n+1}
|
||||||
|
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
|
||||||
|
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
|
||||||
|
|
||||||
|
var reworkJob = new Job
|
||||||
|
{
|
||||||
|
JobNumber = reworkNumber,
|
||||||
|
CustomerId = originalJob.CustomerId,
|
||||||
|
Description = reworkDescription,
|
||||||
|
JobStatusId = firstActiveStatus.Id,
|
||||||
|
JobPriorityId = normalPriority.Id,
|
||||||
|
IsReworkJob = true,
|
||||||
|
OriginalJobId = originalJob.Id,
|
||||||
|
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
|
||||||
|
// Auto-intake: parts are already on hand when rework is logged
|
||||||
|
IntakeDate = DateTime.UtcNow,
|
||||||
|
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
|
||||||
|
IntakeCheckedByUserId = checkedByUserId,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
|
||||||
|
var createdAtUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
foreach (var item in itemsToCopy)
|
||||||
|
{
|
||||||
|
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
||||||
|
|
||||||
|
// Shop-fault rework jobs are done at no charge
|
||||||
|
if (pricingType == ReworkPricingType.ShopFault)
|
||||||
|
{
|
||||||
|
newItem.UnitPrice = 0;
|
||||||
|
newItem.ManualUnitPrice = 0;
|
||||||
|
newItem.TotalPrice = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
||||||
|
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||||
|
|
||||||
|
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
||||||
|
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set intake part count now that items are known
|
||||||
|
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
|
||||||
|
|
||||||
|
// Write a pricing snapshot so the Details page and inline edit both work correctly
|
||||||
|
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
|
||||||
|
? 0m
|
||||||
|
: itemsToCopy.Sum(i => i.TotalPrice);
|
||||||
|
reworkJob.FinalPrice = itemsSubtotal;
|
||||||
|
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
ItemsSubtotal = itemsSubtotal,
|
||||||
|
SubtotalBeforeDiscount = itemsSubtotal,
|
||||||
|
SubtotalAfterDiscount = itemsSubtotal,
|
||||||
|
Total = itemsSubtotal
|
||||||
|
});
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return reworkJob;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates a rework record's status, resolution notes, cost, and billability.
|
/// Updates a rework record's status, resolution notes, cost, and billability.
|
||||||
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
|
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
|
||||||
@@ -3680,66 +3991,6 @@ public class JobsController : Controller
|
|||||||
return Json(new { success = true });
|
return Json(new { success = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new rework Job from an existing rework record and links them.
|
|
||||||
/// The rework job is a lightweight clone of the original job — same customer, description, and
|
|
||||||
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
|
|
||||||
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
|
||||||
{
|
|
||||||
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
|
||||||
if (reworkRecord == null) return NotFound();
|
|
||||||
|
|
||||||
var originalJob = reworkRecord.Job;
|
|
||||||
var companyId = originalJob.CompanyId;
|
|
||||||
|
|
||||||
// Load status lookups to find Pending status
|
|
||||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
|
||||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
|
||||||
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
|
|
||||||
|
|
||||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
|
||||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
|
||||||
|
|
||||||
// Generate job number
|
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
|
||||||
var year = DateTime.Now.ToString("yy");
|
|
||||||
var month = DateTime.Now.ToString("MM");
|
|
||||||
var prefix = $"JOB-{year}{month}-";
|
|
||||||
var maxNum = allJobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
|
||||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
|
||||||
.DefaultIfEmpty(0).Max();
|
|
||||||
|
|
||||||
var reworkJob = new Job
|
|
||||||
{
|
|
||||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
|
||||||
CustomerId = originalJob.CustomerId,
|
|
||||||
Description = $"REWORK: {originalJob.Description}",
|
|
||||||
JobStatusId = pendingStatus.Id,
|
|
||||||
JobPriorityId = normalPriority.Id,
|
|
||||||
IsReworkJob = true,
|
|
||||||
OriginalJobId = originalJob.Id,
|
|
||||||
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
|
|
||||||
CompanyId = companyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
// Link rework record to new job
|
|
||||||
reworkRecord.ReworkJobId = reworkJob.Id;
|
|
||||||
reworkRecord.Status = ReworkStatus.InProgress;
|
|
||||||
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -4148,75 +4399,7 @@ public class JobsController : Controller
|
|||||||
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
// LogMaterial has been consolidated into InventoryController.LogMaterial.
|
||||||
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
|
|
||||||
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
|
|
||||||
/// Quantity is always the amount USED (caller converts from remaining if needed).
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
[ValidateAntiForgeryToken]
|
|
||||||
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (req.QuantityUsed <= 0)
|
|
||||||
return Json(new { success = false, message = "Quantity used must be greater than zero." });
|
|
||||||
|
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
|
|
||||||
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
|
|
||||||
|
|
||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
|
|
||||||
if (job == null) return Json(new { success = false, message = "Job not found." });
|
|
||||||
|
|
||||||
var txnType = req.TransactionType == "Waste"
|
|
||||||
? InventoryTransactionType.Waste
|
|
||||||
: InventoryTransactionType.JobUsage;
|
|
||||||
|
|
||||||
item.QuantityOnHand -= req.QuantityUsed;
|
|
||||||
item.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
|
||||||
|
|
||||||
var txn = new PowderCoating.Core.Entities.InventoryTransaction
|
|
||||||
{
|
|
||||||
InventoryItemId = item.Id,
|
|
||||||
TransactionType = txnType,
|
|
||||||
Quantity = -req.QuantityUsed,
|
|
||||||
UnitCost = item.UnitCost,
|
|
||||||
TotalCost = req.QuantityUsed * item.UnitCost,
|
|
||||||
TransactionDate = DateTime.UtcNow,
|
|
||||||
BalanceAfter = item.QuantityOnHand,
|
|
||||||
JobId = req.JobId,
|
|
||||||
Reference = $"Job {job.JobNumber}",
|
|
||||||
Notes = req.Notes?.Trim(),
|
|
||||||
CompanyId = item.CompanyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(txn);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
// GL: DR COGS, CR Inventory Asset
|
|
||||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
|
||||||
{
|
|
||||||
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
|
||||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
|
||||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Json(new
|
|
||||||
{
|
|
||||||
success = true,
|
|
||||||
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
|
|
||||||
newBalance = item.QuantityOnHand,
|
|
||||||
unitOfMeasure = item.UnitOfMeasure,
|
|
||||||
itemName = item.Name
|
|
||||||
});
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
|
|
||||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Inline-edits description, quantity, and unit price on a single job line item.
|
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||||
@@ -4303,15 +4486,13 @@ public class PatchJobItemRequest
|
|||||||
public decimal Quantity { get; set; }
|
public decimal Quantity { get; set; }
|
||||||
public decimal UnitPrice { get; set; }
|
public decimal UnitPrice { get; set; }
|
||||||
}
|
}
|
||||||
public class LogMaterialRequest
|
public class CreateReworkJobRequest
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int ReworkRecordId { get; set; }
|
||||||
public int InventoryItemId { get; set; }
|
public List<int>? ItemIds { get; set; }
|
||||||
public decimal QuantityUsed { get; set; }
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
public string TransactionType { get; set; } = "JobUsage";
|
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
|
||||||
|
|
||||||
public class UpdateWorkerAssignmentRequest
|
public class UpdateWorkerAssignmentRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -580,7 +580,7 @@ public class MaintenanceController : Controller
|
|||||||
// Calculate next scheduled maintenance
|
// Calculate next scheduled maintenance
|
||||||
if (equipment.RecommendedMaintenanceIntervalDays > 0)
|
if (equipment.RecommendedMaintenanceIntervalDays > 0)
|
||||||
{
|
{
|
||||||
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
|
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
equipment.UpdatedAt = DateTime.UtcNow;
|
equipment.UpdatedAt = DateTime.UtcNow;
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ public class PlatformSubscriptionController : Controller
|
|||||||
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
|
AllowAiInventoryAssist = c.AllowAiInventoryAssist,
|
||||||
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
|
AllowAiCatalogPriceCheck = c.AllowAiCatalogPriceCheck,
|
||||||
AllowSms = c.AllowSms,
|
AllowSms = c.AllowSms,
|
||||||
|
AllowCustomFormulas = c.AllowCustomFormulas,
|
||||||
IsActive = c.IsActive,
|
IsActive = c.IsActive,
|
||||||
SortOrder = c.SortOrder
|
SortOrder = c.SortOrder
|
||||||
}).ToList();
|
}).ToList();
|
||||||
@@ -106,6 +107,7 @@ public class PlatformSubscriptionController : Controller
|
|||||||
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
|
AllowAiInventoryAssist = config.AllowAiInventoryAssist,
|
||||||
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
|
AllowAiCatalogPriceCheck = config.AllowAiCatalogPriceCheck,
|
||||||
AllowSms = config.AllowSms,
|
AllowSms = config.AllowSms,
|
||||||
|
AllowCustomFormulas = config.AllowCustomFormulas,
|
||||||
IsActive = config.IsActive
|
IsActive = config.IsActive
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,6 +154,7 @@ public class PlatformSubscriptionController : Controller
|
|||||||
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
|
config.AllowAiInventoryAssist = dto.AllowAiInventoryAssist;
|
||||||
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
|
config.AllowAiCatalogPriceCheck = dto.AllowAiCatalogPriceCheck;
|
||||||
config.AllowSms = dto.AllowSms;
|
config.AllowSms = dto.AllowSms;
|
||||||
|
config.AllowCustomFormulas = dto.AllowCustomFormulas;
|
||||||
config.IsActive = dto.IsActive;
|
config.IsActive = dto.IsActive;
|
||||||
|
|
||||||
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
|
await _unitOfWork.SubscriptionPlanConfigs.UpdateAsync(config);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ public class QuoteApprovalController : Controller
|
|||||||
private readonly ILogger<QuoteApprovalController> _logger;
|
private readonly ILogger<QuoteApprovalController> _logger;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IHubContext<NotificationHub> _hub;
|
private readonly IHubContext<NotificationHub> _hub;
|
||||||
|
private readonly IQuotePricingAssemblyService _assemblyService;
|
||||||
|
|
||||||
public QuoteApprovalController(
|
public QuoteApprovalController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -36,7 +37,8 @@ public class QuoteApprovalController : Controller
|
|||||||
IStripeConnectService stripeConnect,
|
IStripeConnectService stripeConnect,
|
||||||
ILogger<QuoteApprovalController> logger,
|
ILogger<QuoteApprovalController> logger,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IHubContext<NotificationHub> hub)
|
IHubContext<NotificationHub> hub,
|
||||||
|
IQuotePricingAssemblyService assemblyService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_notifications = notifications;
|
_notifications = notifications;
|
||||||
@@ -45,6 +47,7 @@ public class QuoteApprovalController : Controller
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_hub = hub;
|
_hub = hub;
|
||||||
|
_assemblyService = assemblyService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -177,6 +180,16 @@ public class QuoteApprovalController : Controller
|
|||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// Create incoming inventory records for catalog-sourced coats deferred from quote-save time.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _assemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(quote.Id, quote.CompanyId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", quote.Id);
|
||||||
|
}
|
||||||
|
|
||||||
var approveEntry = new QuoteChangeHistory
|
var approveEntry = new QuoteChangeHistory
|
||||||
{
|
{
|
||||||
QuoteId = quote.Id,
|
QuoteId = quote.Id,
|
||||||
|
|||||||
@@ -393,6 +393,8 @@ public class QuotesController : Controller
|
|||||||
OvenBatchCost = quote.OvenBatchCost,
|
OvenBatchCost = quote.OvenBatchCost,
|
||||||
OvenBatches = quote.OvenBatches,
|
OvenBatches = quote.OvenBatches,
|
||||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0,
|
OvenCycleMinutes = quote.OvenCycleMinutes ?? operatingCosts?.DefaultOvenCycleMinutes ?? 0,
|
||||||
|
FacilityOverheadCost = quote.FacilityOverheadCost,
|
||||||
|
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
|
||||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||||
OverheadCosts = quote.OverheadAmount,
|
OverheadCosts = quote.OverheadAmount,
|
||||||
@@ -577,6 +579,8 @@ public class QuotesController : Controller
|
|||||||
OvenBatchCost = quote.OvenBatchCost,
|
OvenBatchCost = quote.OvenBatchCost,
|
||||||
OvenBatches = quote.OvenBatches,
|
OvenBatches = quote.OvenBatches,
|
||||||
OvenCycleMinutes = quote.OvenCycleMinutes ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0,
|
OvenCycleMinutes = quote.OvenCycleMinutes ?? pdfOperatingCosts?.DefaultOvenCycleMinutes ?? 0,
|
||||||
|
FacilityOverheadCost = quote.FacilityOverheadCost,
|
||||||
|
FacilityOverheadRatePerHour = quote.FacilityOverheadRatePerHour,
|
||||||
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
ShopSuppliesAmount = quote.ShopSuppliesAmount,
|
||||||
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
ShopSuppliesPercent = quote.ShopSuppliesPercent,
|
||||||
OverheadCosts = quote.OverheadAmount,
|
OverheadCosts = quote.OverheadAmount,
|
||||||
@@ -1316,6 +1320,7 @@ public class QuotesController : Controller
|
|||||||
Terms = quote.Terms,
|
Terms = quote.Terms,
|
||||||
Notes = quote.Notes,
|
Notes = quote.Notes,
|
||||||
TaxPercent = quote.TaxPercent,
|
TaxPercent = quote.TaxPercent,
|
||||||
|
Total = quote.Total,
|
||||||
DiscountType = quote.DiscountType,
|
DiscountType = quote.DiscountType,
|
||||||
DiscountValue = quote.DiscountValue,
|
DiscountValue = quote.DiscountValue,
|
||||||
DiscountReason = quote.DiscountReason,
|
DiscountReason = quote.DiscountReason,
|
||||||
@@ -1338,9 +1343,27 @@ public class QuotesController : Controller
|
|||||||
// Set calculated pricing — snapshot at save time; never recalculate on load
|
// Set calculated pricing — snapshot at save time; never recalculate on load
|
||||||
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
|
_quotePricingAssemblyService.ApplyPricingSnapshot(quote, pricingResult);
|
||||||
|
|
||||||
// Track changes
|
// All change history records are accumulated here, then saved in bulk below
|
||||||
var changeHistories = new List<QuoteChangeHistory>();
|
var changeHistories = new List<QuoteChangeHistory>();
|
||||||
|
|
||||||
|
// Log a total-change entry now that the new Total is known
|
||||||
|
if (Math.Round(oldValues.Total, 2) != Math.Round(quote.Total, 2))
|
||||||
|
{
|
||||||
|
changeHistories.Add(new QuoteChangeHistory
|
||||||
|
{
|
||||||
|
QuoteId = quote.Id,
|
||||||
|
ChangedByUserId = currentUser.Id,
|
||||||
|
ChangedAt = DateTime.UtcNow,
|
||||||
|
FieldName = "Total",
|
||||||
|
OldValue = oldValues.Total.ToString("C"),
|
||||||
|
NewValue = quote.Total.ToString("C"),
|
||||||
|
ChangeDescription = $"Total changed from {oldValues.Total:C} to {quote.Total:C}",
|
||||||
|
CompanyId = currentUser.CompanyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track changes
|
||||||
|
|
||||||
_logger.LogInformation("=== CHANGE TRACKING DEBUG ===");
|
_logger.LogInformation("=== CHANGE TRACKING DEBUG ===");
|
||||||
_logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId);
|
_logger.LogInformation("Old Status: {OldStatus}, New Status: {NewStatus}", oldValues.QuoteStatusId, quote.QuoteStatusId);
|
||||||
_logger.LogInformation("Old Date: {OldDate}, New Date: {NewDate}", oldValues.QuoteDate, quote.QuoteDate);
|
_logger.LogInformation("Old Date: {OldDate}, New Date: {NewDate}", oldValues.QuoteDate, quote.QuoteDate);
|
||||||
@@ -1934,12 +1957,10 @@ public class QuotesController : Controller
|
|||||||
if (dto.SmsConsent)
|
if (dto.SmsConsent)
|
||||||
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
await _notificationService.NotifySmsConsentGrantedAsync(customer);
|
||||||
|
|
||||||
// Get "Converted" status (cached)
|
// Update quote to link to new customer.
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
// Do NOT set "Converted" status here — that status is reserved for when a job is
|
||||||
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId);
|
// actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
|
||||||
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted);
|
// user immediately click "Create Job from Quote" on the next screen.
|
||||||
|
|
||||||
// Update quote to link to new customer
|
|
||||||
quote.CustomerId = customer.Id;
|
quote.CustomerId = customer.Id;
|
||||||
|
|
||||||
// Clear prospect fields
|
// Clear prospect fields
|
||||||
@@ -1954,14 +1975,11 @@ public class QuotesController : Controller
|
|||||||
quote.ProspectSmsConsent = false;
|
quote.ProspectSmsConsent = false;
|
||||||
quote.ProspectSmsConsentedAt = null;
|
quote.ProspectSmsConsentedAt = null;
|
||||||
|
|
||||||
// Update status to converted
|
|
||||||
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
|
|
||||||
|
|
||||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated.");
|
this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
|
||||||
return RedirectToAction("Details", "Customers", new { id = customer.Id });
|
return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -2313,6 +2331,17 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
_logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id);
|
_logger.LogInformation("Quote {QuoteId} approved by user {UserId}", id, currentUser.Id);
|
||||||
|
|
||||||
|
// Create incoming inventory records for any catalog-sourced coats that were deferred
|
||||||
|
// from quote-save time. One record per unique powder catalog item, de-duplicated.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(id, quote.CompanyId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", id);
|
||||||
|
}
|
||||||
|
|
||||||
// Notify customer that quote is approved (only if user opted in)
|
// Notify customer that quote is approved (only if user opted in)
|
||||||
if (sendEmail)
|
if (sendEmail)
|
||||||
{
|
{
|
||||||
@@ -2429,6 +2458,33 @@ public class QuotesController : Controller
|
|||||||
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
|
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
|
||||||
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
|
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
|
||||||
|
|
||||||
|
var allowFormulas = HttpContext.Items["AllowCustomFormulas"] as bool? ?? false;
|
||||||
|
if (allowFormulas)
|
||||||
|
{
|
||||||
|
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId && t.IsActive);
|
||||||
|
ViewBag.CustomFormulaTemplates = formulaTemplates
|
||||||
|
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
id = t.Id,
|
||||||
|
name = t.Name,
|
||||||
|
description = t.Description,
|
||||||
|
outputMode = t.OutputMode,
|
||||||
|
fieldsJson = t.FieldsJson,
|
||||||
|
formula = t.Formula,
|
||||||
|
defaultRate = t.DefaultRate,
|
||||||
|
rateLabel = t.RateLabel,
|
||||||
|
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
|
||||||
|
? (string?)null
|
||||||
|
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewBag.CustomFormulaTemplates = new List<object>();
|
||||||
|
}
|
||||||
|
|
||||||
// Customers
|
// Customers
|
||||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = customers
|
ViewBag.Customers = customers
|
||||||
@@ -2771,6 +2827,20 @@ public class QuotesController : Controller
|
|||||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
|
||||||
|
// When transitioning to Approved: create incoming inventory records for catalog-sourced
|
||||||
|
// coats that were deferred from quote-save time (one record per unique powder, deduplicated).
|
||||||
|
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _quotePricingAssemblyService.EnsureIncomingInventoryForApprovedQuoteAsync(request.QuoteId, quote.CompanyId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "EnsureIncomingInventory failed for quote {QuoteId} — continuing", request.QuoteId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-create job when quote is approved — guard against double-conversion
|
// Auto-create job when quote is approved — guard against double-conversion
|
||||||
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
|
// (race condition: two simultaneous approval calls could both pass the oldStatusCode check)
|
||||||
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
|
if (newStatus.StatusCode == AppConstants.StatusCodes.Quote.Approved && oldStatusCode != AppConstants.StatusCodes.Quote.Approved
|
||||||
@@ -2883,6 +2953,7 @@ public class QuotesController : Controller
|
|||||||
Total = quote.Total
|
Total = quote.Total
|
||||||
}),
|
}),
|
||||||
CustomerPO = quote.CustomerPO,
|
CustomerPO = quote.CustomerPO,
|
||||||
|
ProjectName = quote.ProjectName,
|
||||||
InternalNotes = quote.Notes, // Copy internal notes from quote
|
InternalNotes = quote.Notes, // Copy internal notes from quote
|
||||||
IsCustomerApproved = true,
|
IsCustomerApproved = true,
|
||||||
IsRushJob = quote.IsRushJob,
|
IsRushJob = quote.IsRushJob,
|
||||||
@@ -3118,6 +3189,22 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.Quotes.UpdateAsync(quote);
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
|
|
||||||
|
// Log send event so the history timeline shows when the quote was emailed
|
||||||
|
var sentHistoryEntry = new QuoteChangeHistory
|
||||||
|
{
|
||||||
|
QuoteId = quote.Id,
|
||||||
|
ChangedByUserId = currentUser!.Id,
|
||||||
|
ChangedAt = DateTime.UtcNow,
|
||||||
|
FieldName = "Sent",
|
||||||
|
OldValue = null,
|
||||||
|
NewValue = recipientEmail,
|
||||||
|
ChangeDescription = $"Quote sent to {recipientName} ({recipientEmail})",
|
||||||
|
CompanyId = quote.CompanyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
await _unitOfWork.QuoteChangeHistories.AddAsync(sentHistoryEntry);
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
|
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
|
||||||
@@ -3344,13 +3431,21 @@ public class QuotesController : Controller
|
|||||||
// Build company AI context: profile text + recent accepted predictions as few-shot examples
|
// Build company AI context: profile text + recent accepted predictions as few-shot examples
|
||||||
var aiContext = await BuildCompanyAiContextAsync(companyId, costs);
|
var aiContext = await BuildCompanyAiContextAsync(companyId, costs);
|
||||||
|
|
||||||
// Load the specific blast setup when the user picked one before analyzing
|
// Load the specific blast setup when the user picked one before analyzing.
|
||||||
|
// If none was explicitly chosen, fall back to the company's default blast setup so
|
||||||
|
// named-setup rates (e.g. a blast cabinet configured at 82 sqft/hr) are always
|
||||||
|
// used instead of the coarser company-level operating cost fallback.
|
||||||
CompanyBlastSetup? selectedBlastSetup = null;
|
CompanyBlastSetup? selectedBlastSetup = null;
|
||||||
if (request.BlastSetupId.HasValue)
|
if (request.BlastSetupId.HasValue)
|
||||||
{
|
{
|
||||||
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
|
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
|
||||||
selectedBlastSetup = setups.FirstOrDefault();
|
selectedBlastSetup = setups.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var defaultSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsDefault && b.IsActive && b.CompanyId == companyId);
|
||||||
|
selectedBlastSetup = defaultSetups.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup);
|
var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup);
|
||||||
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
|
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
|
||||||
|
|||||||
@@ -1722,6 +1722,129 @@ public class ReportsController : Controller
|
|||||||
return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg });
|
return View(new JobCycleTimeViewModel { ReportTitle = "Job Cycle Time", ReportDescription = "Average time spent in each workflow stage", SelectedMonths = months, Items = items, OverallAvgCycleDays = overallAvg });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Job Profitability report — compares each job's final price and collected amount against
|
||||||
|
/// actual labor cost (hours × StandardLaborRate) and estimated powder cost (PowderToOrder ×
|
||||||
|
/// PowderCostPerLb, or ActualPowderUsedLbs when recorded). Only jobs with at least one
|
||||||
|
/// JobTimeEntry contribute meaningful labor cost figures; the report flags rows without time
|
||||||
|
/// tracking so the user knows which margin estimates are incomplete.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> JobProfitability(int months = 6, bool timeTrackedOnly = false)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var cutoff = DateTime.UtcNow.AddMonths(-months);
|
||||||
|
|
||||||
|
// ── Load base data ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var jobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => j.CompanyId == companyId && j.CreatedAt >= cutoff,
|
||||||
|
false,
|
||||||
|
j => j.Customer,
|
||||||
|
j => j.JobStatus))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var jobIds = jobs.Select(j => j.Id).ToList();
|
||||||
|
|
||||||
|
// Time entries grouped by job
|
||||||
|
var timeEntries = (await _unitOfWork.JobTimeEntries.FindAsync(
|
||||||
|
te => jobIds.Contains(te.JobId) && !te.IsDeleted))
|
||||||
|
.GroupBy(te => te.JobId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(te => te.HoursWorked));
|
||||||
|
|
||||||
|
// Job items, then coats grouped by job
|
||||||
|
var jobItems = (await _unitOfWork.JobItems.FindAsync(
|
||||||
|
ji => jobIds.Contains(ji.JobId) && !ji.IsDeleted))
|
||||||
|
.ToList();
|
||||||
|
var jobItemIds = jobItems.Select(ji => ji.Id).ToList();
|
||||||
|
|
||||||
|
var coats = jobItemIds.Any()
|
||||||
|
? (await _unitOfWork.JobItemCoats.FindAsync(
|
||||||
|
c => jobItemIds.Contains(c.JobItemId) && !c.IsDeleted))
|
||||||
|
.ToList()
|
||||||
|
: new List<JobItemCoat>();
|
||||||
|
|
||||||
|
// Map coat cost totals back to JobId
|
||||||
|
var jobItemToJob = jobItems.ToDictionary(ji => ji.Id, ji => ji.JobId);
|
||||||
|
var powderCostByJob = coats
|
||||||
|
.Where(c => c.PowderCostPerLb.HasValue)
|
||||||
|
.GroupBy(c => jobItemToJob.TryGetValue(c.JobItemId, out var jid) ? jid : 0)
|
||||||
|
.Where(g => g.Key > 0)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => g.Sum(c =>
|
||||||
|
{
|
||||||
|
var lbs = c.ActualPowderUsedLbs ?? c.PowderToOrder ?? 0m;
|
||||||
|
return lbs * (c.PowderCostPerLb ?? 0m);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Invoices grouped by job
|
||||||
|
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
||||||
|
inv => inv.CompanyId == companyId && inv.JobId.HasValue
|
||||||
|
&& jobIds.Contains(inv.JobId!.Value)
|
||||||
|
&& !inv.IsDeleted))
|
||||||
|
.GroupBy(inv => inv.JobId!.Value)
|
||||||
|
.ToDictionary(g => g.Key, g => g.Sum(inv => inv.AmountPaid));
|
||||||
|
|
||||||
|
// Labor rate
|
||||||
|
var opCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(
|
||||||
|
c => c.CompanyId == companyId && !c.IsDeleted);
|
||||||
|
var laborRate = opCosts?.StandardLaborRate ?? 0m;
|
||||||
|
|
||||||
|
// ── Build report rows ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
var items = jobs
|
||||||
|
.Select(j =>
|
||||||
|
{
|
||||||
|
var hours = timeEntries.TryGetValue(j.Id, out var h) ? h : 0m;
|
||||||
|
var powderCost = powderCostByJob.TryGetValue(j.Id, out var pc) ? pc : 0m;
|
||||||
|
var collected = invoices.TryGetValue(j.Id, out var paid) ? paid : 0m;
|
||||||
|
|
||||||
|
return new JobProfitabilityItem
|
||||||
|
{
|
||||||
|
JobId = j.Id,
|
||||||
|
JobNumber = j.JobNumber,
|
||||||
|
CustomerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
|
||||||
|
? j.Customer.CompanyName
|
||||||
|
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim()
|
||||||
|
is { Length: > 0 } n ? n : "Unknown",
|
||||||
|
StatusName = j.JobStatus?.DisplayName ?? "Unknown",
|
||||||
|
StatusColorClass = j.JobStatus?.ColorClass ?? "bg-secondary",
|
||||||
|
JobDate = j.CreatedAt,
|
||||||
|
FinalPrice = j.FinalPrice,
|
||||||
|
AmountCollected = collected,
|
||||||
|
ActualHours = hours,
|
||||||
|
ActualLaborCost = Math.Round(hours * laborRate, 2),
|
||||||
|
ActualPowderCost = Math.Round(powderCost, 2),
|
||||||
|
HasTimeEntries = timeEntries.ContainsKey(j.Id),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.Where(r => !timeTrackedOnly || r.HasTimeEntries)
|
||||||
|
.OrderByDescending(r => r.JobDate)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// ── Summary KPIs ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
var itemsWithCost = items.Where(r => r.EstimatedTotalCost > 0).ToList();
|
||||||
|
|
||||||
|
return View(new JobProfitabilityViewModel
|
||||||
|
{
|
||||||
|
ReportTitle = "Job Profitability",
|
||||||
|
ReportDescription = "Actual labor and powder cost vs. billed price per job",
|
||||||
|
SelectedMonths = months,
|
||||||
|
TimeTrackedOnly = timeTrackedOnly,
|
||||||
|
TotalJobs = items.Count,
|
||||||
|
JobsWithTimeEntries = items.Count(r => r.HasTimeEntries),
|
||||||
|
TotalRevenue = items.Sum(r => r.FinalPrice),
|
||||||
|
TotalCollected = items.Sum(r => r.AmountCollected),
|
||||||
|
TotalEstimatedCost = itemsWithCost.Sum(r => r.EstimatedTotalCost),
|
||||||
|
TotalActualHours = items.Sum(r => r.ActualHours),
|
||||||
|
AvgMarginPercent = itemsWithCost.Any()
|
||||||
|
? Math.Round(itemsWithCost.Average(r => r.MarginPercent), 1)
|
||||||
|
: 0m,
|
||||||
|
Items = items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current
|
/// Job Status Aging report — all active (non-terminal) jobs sorted by days in their current
|
||||||
/// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status"
|
/// status descending. Uses UpdatedAt as the proxy for "when did this job enter its current status"
|
||||||
@@ -2475,6 +2598,231 @@ public class ReportsController : Controller
|
|||||||
return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
|
return View(rows.OrderByDescending(r => r.TotalPaid).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Timeclock / Attendance
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attendance report: daily punch detail and weekly summaries per employee.
|
||||||
|
/// Managers can see all employees; Workers see their own history only.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> Attendance(string? mode, string? week, string? month)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Forbid();
|
||||||
|
|
||||||
|
var companyId = currentUser.CompanyId;
|
||||||
|
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||||
|
|
||||||
|
var period = ResolveAttendancePeriod(mode, week, month);
|
||||||
|
|
||||||
|
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||||
|
e => e.CompanyId == companyId
|
||||||
|
&& e.ClockInTime >= period.Start
|
||||||
|
&& e.ClockInTime < period.End
|
||||||
|
&& (isManager || e.UserId == currentUser.Id),
|
||||||
|
false,
|
||||||
|
e => e.User);
|
||||||
|
|
||||||
|
var grouped = entries
|
||||||
|
.GroupBy(e => e.UserId)
|
||||||
|
.Select(g => new AttendanceEmployeeRow
|
||||||
|
{
|
||||||
|
UserId = g.Key,
|
||||||
|
DisplayName = g.First().User?.FullName ?? "Unknown",
|
||||||
|
// Only Work segments count toward paid hours
|
||||||
|
TotalHours = Math.Round(g.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
|
||||||
|
Days = g.GroupBy(e => e.ClockInTime.Date)
|
||||||
|
.OrderByDescending(d => d.Key)
|
||||||
|
.Select(d => new AttendanceDayRow
|
||||||
|
{
|
||||||
|
Date = d.Key,
|
||||||
|
DayTotal = Math.Round(d.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).Sum(e => e.HoursWorked ?? 0), 2),
|
||||||
|
Segments = d.OrderBy(e => e.ClockInTime).ToList()
|
||||||
|
}).ToList()
|
||||||
|
})
|
||||||
|
.OrderBy(r => r.DisplayName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
ViewBag.Period = period;
|
||||||
|
ViewBag.IsManager = isManager;
|
||||||
|
return View(grouped);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports the attendance data for the selected date range as a CSV file suitable for
|
||||||
|
/// import into payroll software. Each row is one clock segment (punch pair); employee name,
|
||||||
|
/// date, clock in/out times, segment hours, day total, and week total are all repeated on
|
||||||
|
/// every row so the file is fully self-contained in flat-file format.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> AttendanceCsv(string? mode, string? week, string? month)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Forbid();
|
||||||
|
|
||||||
|
var companyId = currentUser.CompanyId;
|
||||||
|
var isManager = currentUser.CompanyRole is "CompanyAdmin" or "Manager";
|
||||||
|
|
||||||
|
var period = ResolveAttendancePeriod(mode, week, month);
|
||||||
|
var start = period.Start;
|
||||||
|
var end = period.End;
|
||||||
|
|
||||||
|
var entries = await _unitOfWork.EmployeeClockEntries.FindAsync(
|
||||||
|
e => e.CompanyId == companyId
|
||||||
|
&& e.ClockInTime >= start
|
||||||
|
&& e.ClockInTime < end
|
||||||
|
&& (isManager || e.UserId == currentUser.Id),
|
||||||
|
false,
|
||||||
|
e => e.User);
|
||||||
|
|
||||||
|
// Build flat CSV rows sorted by employee name → date → clock-in time
|
||||||
|
var rows = entries
|
||||||
|
.OrderBy(e => e.User?.FullName ?? "")
|
||||||
|
.ThenBy(e => e.ClockInTime)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Day/week totals count only Work segments (Break/Lunch are unpaid)
|
||||||
|
var workEntries = entries.Where(e => e.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work).ToList();
|
||||||
|
|
||||||
|
var dayTotals = workEntries
|
||||||
|
.GroupBy(e => (e.UserId, e.ClockInTime.Date))
|
||||||
|
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
||||||
|
|
||||||
|
// ISO week: group by (UserId, year, week number) where week starts Monday
|
||||||
|
static int IsoWeek(DateTime d) =>
|
||||||
|
System.Globalization.ISOWeek.GetWeekOfYear(d);
|
||||||
|
|
||||||
|
var weekTotals = workEntries
|
||||||
|
.GroupBy(e => (e.UserId, e.ClockInTime.Year, IsoWeek(e.ClockInTime)))
|
||||||
|
.ToDictionary(g => g.Key, g => Math.Round(g.Sum(e => e.HoursWorked ?? 0), 2));
|
||||||
|
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("Employee Name,Date,Day of Week,Type,Clock In,Clock Out,Segment Hours,Day Total Hours,Week Total Hours,Notes");
|
||||||
|
|
||||||
|
foreach (var entry in rows)
|
||||||
|
{
|
||||||
|
var name = CsvEscape(entry.User?.FullName ?? "Unknown");
|
||||||
|
var date = entry.ClockInTime.ToLocalTime().ToString("yyyy-MM-dd");
|
||||||
|
var dow = entry.ClockInTime.ToLocalTime().DayOfWeek.ToString();
|
||||||
|
var entryType = entry.EntryType.ToString(); // Work, Break, or Lunch
|
||||||
|
var clockIn = entry.ClockInTime.ToLocalTime().ToString("h:mm tt");
|
||||||
|
var clockOut = entry.ClockOutTime.HasValue
|
||||||
|
? entry.ClockOutTime.Value.ToLocalTime().ToString("h:mm tt")
|
||||||
|
: "In Progress";
|
||||||
|
var segHrs = entry.HoursWorked.HasValue
|
||||||
|
? entry.HoursWorked.Value.ToString("F2")
|
||||||
|
: "";
|
||||||
|
// Day/week totals are blank for Break/Lunch rows — they only appear on Work rows
|
||||||
|
var isWork = entry.EntryType == PowderCoating.Core.Enums.ClockEntryType.Work;
|
||||||
|
var dayKey = (entry.UserId, entry.ClockInTime.Date);
|
||||||
|
var weekKey = (entry.UserId, entry.ClockInTime.Year, IsoWeek(entry.ClockInTime));
|
||||||
|
var dayTotal = isWork && dayTotals.TryGetValue(dayKey, out var d) ? d.ToString("F2") : "";
|
||||||
|
var wkTotal = isWork && weekTotals.TryGetValue(weekKey, out var w) ? w.ToString("F2") : "";
|
||||||
|
var notes = CsvEscape(entry.Notes ?? "");
|
||||||
|
|
||||||
|
sb.AppendLine($"{name},{date},{dow},{entryType},{clockIn},{clockOut},{segHrs},{dayTotal},{wkTotal},{notes}");
|
||||||
|
}
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
var safeName = (company?.CompanyName ?? "Company").Replace(" ", "_");
|
||||||
|
var safePeriod = period.PeriodLabel.Replace(" ", "_").Replace("–", "-").Replace(",", "");
|
||||||
|
var filename = $"{safeName}_Attendance_{safePeriod}.csv";
|
||||||
|
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(sb.ToString()), "text/csv", filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CsvEscape(string value)
|
||||||
|
{
|
||||||
|
if (value.Contains(',') || value.Contains('"') || value.Contains('\n'))
|
||||||
|
return $"\"{value.Replace("\"", "\"\"")}\"";
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Attendance period helpers
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves query start/end dates and picker display state from the attendance
|
||||||
|
/// report's mode/week/month URL params. Defaults to the current ISO week.
|
||||||
|
/// Shared by <see cref="Attendance"/> and <see cref="AttendanceCsv"/> so both
|
||||||
|
/// actions always query exactly the same date range for a given URL.
|
||||||
|
/// </summary>
|
||||||
|
private static AttendancePeriod ResolveAttendancePeriod(string? mode, string? week, string? month)
|
||||||
|
{
|
||||||
|
if (mode?.ToLower() == "month")
|
||||||
|
{
|
||||||
|
var monthDate = month != null
|
||||||
|
&& DateTime.TryParseExact(month + "-01", "yyyy-MM-dd",
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
System.Globalization.DateTimeStyles.None, out var md)
|
||||||
|
? md
|
||||||
|
: new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1);
|
||||||
|
|
||||||
|
return new AttendancePeriod
|
||||||
|
{
|
||||||
|
Mode = "month",
|
||||||
|
Start = monthDate,
|
||||||
|
End = monthDate.AddMonths(1),
|
||||||
|
WeekValue = IsoWeekValue(DateTime.UtcNow),
|
||||||
|
MonthValue = monthDate.ToString("yyyy-MM"),
|
||||||
|
PeriodLabel = monthDate.ToString("MMMM yyyy")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var weekStart = TryParseIsoWeek(week) ?? CurrentIsoWeekStart();
|
||||||
|
var weekEnd = weekStart.AddDays(6);
|
||||||
|
|
||||||
|
var label = weekStart.Year != weekEnd.Year
|
||||||
|
? $"{weekStart:MMM d, yyyy} – {weekEnd:MMM d, yyyy}"
|
||||||
|
: weekStart.Month != weekEnd.Month
|
||||||
|
? $"Week of {weekStart:MMM d} – {weekEnd:MMM d, yyyy}"
|
||||||
|
: $"Week of {weekStart:MMMM d} – {weekEnd:d, yyyy}";
|
||||||
|
|
||||||
|
return new AttendancePeriod
|
||||||
|
{
|
||||||
|
Mode = "week",
|
||||||
|
Start = weekStart,
|
||||||
|
End = weekStart.AddDays(7),
|
||||||
|
WeekValue = IsoWeekValue(weekStart),
|
||||||
|
MonthValue = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1).ToString("yyyy-MM"),
|
||||||
|
PeriodLabel = label
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the Monday of the current ISO week in UTC.</summary>
|
||||||
|
private static DateTime CurrentIsoWeekStart()
|
||||||
|
{
|
||||||
|
var today = DateTime.UtcNow.Date;
|
||||||
|
return System.Globalization.ISOWeek.ToDateTime(
|
||||||
|
System.Globalization.ISOWeek.GetYear(today),
|
||||||
|
System.Globalization.ISOWeek.GetWeekOfYear(today),
|
||||||
|
DayOfWeek.Monday);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Formats a date as the ISO week string used by the HTML week picker (e.g. "2026-W22").</summary>
|
||||||
|
private static string IsoWeekValue(DateTime d) =>
|
||||||
|
$"{System.Globalization.ISOWeek.GetYear(d):D4}-W{System.Globalization.ISOWeek.GetWeekOfYear(d):D2}";
|
||||||
|
|
||||||
|
/// <summary>Parses an ISO week string (e.g. "2026-W22") to the Monday of that week. Returns null on failure.</summary>
|
||||||
|
private static DateTime? TryParseIsoWeek(string? value)
|
||||||
|
{
|
||||||
|
if (value == null) return null;
|
||||||
|
var parts = value.Split("-W");
|
||||||
|
if (parts.Length == 2
|
||||||
|
&& int.TryParse(parts[0], out int yr) && yr is >= 2020 and <= 2100
|
||||||
|
&& int.TryParse(parts[1], out int wk) && wk is >= 1 and <= 53)
|
||||||
|
{
|
||||||
|
try { return System.Globalization.ISOWeek.ToDateTime(yr, wk, DayOfWeek.Monday); }
|
||||||
|
catch { /* invalid week number for that year */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
|
/// Looks up the current tenant's company name from the CompanyId claim. Used to inject the
|
||||||
/// company name into AI prompts so the generated text refers to the actual business, not a
|
/// company name into AI prompts so the generated text refers to the actual business, not a
|
||||||
@@ -2628,3 +2976,30 @@ public class Vendor1099Row
|
|||||||
public bool NeedsForm { get; set; }
|
public bool NeedsForm { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class AttendanceEmployeeRow
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public decimal TotalHours { get; set; }
|
||||||
|
public List<AttendanceDayRow> Days { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AttendanceDayRow
|
||||||
|
{
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public decimal DayTotal { get; set; }
|
||||||
|
public List<PowderCoating.Core.Entities.EmployeeClockEntry> Segments { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Resolved date range and picker state for the attendance report.</summary>
|
||||||
|
public record AttendancePeriod
|
||||||
|
{
|
||||||
|
public string Mode { get; init; } = "week";
|
||||||
|
public DateTime Start { get; init; }
|
||||||
|
/// <summary>Exclusive upper bound for EF queries (Start + 7 days or +1 month).</summary>
|
||||||
|
public DateTime End { get; init; }
|
||||||
|
public string WeekValue { get; init; } = ""; // "2026-W22"
|
||||||
|
public string MonthValue { get; init; } = ""; // "2026-05"
|
||||||
|
public string PeriodLabel { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user