6721de91e4
- Store complete PricingBreakdownJson snapshot on Job at every save point so the Details page reads stored data rather than re-running the pricing engine - Add 7 missing fields to Quote entity (FacilityOverheadCost, tier/quote discounts, SubtotalAfterDiscount) and persist them via ApplyPricingSnapshot - Fix OvenCostId-as-rate bug in JobsController (FK was passed as decimal $/hr) - Fix hardcoded LaborCost * 0.4 multiplier in two JobItemAssemblyService overloads - Fix FacilityOverheadCost dropped from invoices in both quote and direct-job paths - Fix RushFee missing from direct-job invoices (read from PricingBreakdownJson) - Fix Notes and CatalogItemId not copied to InvoiceItem - Add 14 unit tests in PricingStageFlowTests covering all three pricing stages Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
877 lines
29 KiB
C#
877 lines
29 KiB
C#
using System.ComponentModel.DataAnnotations;
|
||
using PowderCoating.Application.DTOs.PrepService;
|
||
|
||
namespace PowderCoating.Application.DTOs.Quote;
|
||
|
||
// ============================================================================
|
||
// LIST DTO - For Index page listing
|
||
// ============================================================================
|
||
public class QuoteListDto
|
||
{
|
||
public int Id { get; set; }
|
||
public string QuoteNumber { get; set; } = string.Empty;
|
||
public string CustomerOrProspectName { get; set; } = string.Empty;
|
||
|
||
// Quote Status (from lookup table)
|
||
public int QuoteStatusId { get; set; }
|
||
public string StatusCode { get; set; } = string.Empty;
|
||
public string StatusDisplayName { get; set; } = string.Empty;
|
||
public string StatusColorClass { get; set; } = "secondary";
|
||
public bool StatusIsDraft { get; set; }
|
||
|
||
public DateTime QuoteDate { get; set; }
|
||
public DateTime? ExpirationDate { get; set; }
|
||
public decimal Total { get; set; }
|
||
public string? Notes { get; set; }
|
||
public string? Description { get; set; }
|
||
public string? Tags { get; set; }
|
||
public bool IsExpired => ExpirationDate.HasValue && ExpirationDate.Value < DateTime.UtcNow && StatusIsDraft;
|
||
public bool IsProspect { get; set; }
|
||
}
|
||
|
||
// ============================================================================
|
||
// FULL QUOTE DTO - For Details page
|
||
// ============================================================================
|
||
public class QuoteDto
|
||
{
|
||
public int Id { get; set; }
|
||
public string QuoteNumber { get; set; } = string.Empty;
|
||
|
||
// Customer or Prospect Info
|
||
public int? CustomerId { get; set; }
|
||
public string? CustomerName { get; set; }
|
||
public string? CustomerCompanyName { get; set; }
|
||
public string? CustomerContactFirstName { get; set; }
|
||
public string? CustomerContactLastName { get; set; }
|
||
public string? CustomerEmail { get; set; }
|
||
public string? CustomerPhone { get; set; }
|
||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||
public string? CustomerMobilePhone { get; set; }
|
||
public bool CustomerNotifyBySms { get; set; }
|
||
|
||
public string? ProspectCompanyName { get; set; }
|
||
public string? ProspectContactFirstName { get; set; }
|
||
public string? ProspectContactLastName { get; set; }
|
||
public string? ProspectContactName { get; set; }
|
||
public string? ProspectEmail { get; set; }
|
||
public string? ProspectPhone { get; set; }
|
||
public string? ProspectAddress { get; set; }
|
||
public string? ProspectCity { get; set; }
|
||
public string? ProspectState { get; set; }
|
||
public string? ProspectZipCode { get; set; }
|
||
public bool ProspectSmsConsent { get; set; }
|
||
public DateTime? ProspectSmsConsentedAt { get; set; }
|
||
|
||
public string? PreparedById { get; set; }
|
||
public string? PreparedByName { get; set; }
|
||
public string? PreparedByEmail { get; set; }
|
||
|
||
// Quote Status (from lookup table)
|
||
public int QuoteStatusId { get; set; }
|
||
public string StatusCode { get; set; } = string.Empty;
|
||
public string StatusDisplayName { get; set; } = string.Empty;
|
||
public string StatusColorClass { get; set; } = "secondary";
|
||
public string? StatusIconClass { get; set; }
|
||
public bool StatusIsApproved { get; set; }
|
||
public bool StatusIsConverted { get; set; }
|
||
public bool StatusIsDraft { get; set; }
|
||
|
||
public bool IsCommercial { get; set; }
|
||
|
||
// Dates
|
||
public DateTime QuoteDate { get; set; }
|
||
public DateTime? ExpirationDate { get; set; }
|
||
public DateTime? SentDate { get; set; }
|
||
public DateTime? ApprovedDate { get; set; }
|
||
|
||
// Pricing
|
||
public decimal SubTotal { get; set; }
|
||
|
||
// Discount Information
|
||
public string DiscountType { get; set; } = "None"; // None, Percentage, FixedAmount
|
||
public decimal DiscountValue { get; set; }
|
||
public decimal DiscountPercent { get; set; }
|
||
public decimal DiscountAmount { get; set; }
|
||
public string? DiscountReason { get; set; }
|
||
public bool HideDiscountFromCustomer { get; set; }
|
||
|
||
public decimal TaxPercent { get; set; }
|
||
public decimal TaxAmount { get; set; }
|
||
public decimal RushFee { get; set; }
|
||
public decimal Total { get; set; }
|
||
|
||
public bool IsRushJob { get; set; }
|
||
|
||
// Additional Information
|
||
public string? Description { get; set; }
|
||
public string? Terms { get; set; }
|
||
public string? Notes { get; set; }
|
||
public string? CustomerPO { get; set; }
|
||
public string? Tags { get; set; }
|
||
|
||
// Items
|
||
public List<QuoteItemDto> QuoteItems { get; set; } = new();
|
||
|
||
// Prep Services
|
||
public List<PrepServiceDto> PrepServices { get; set; } = new();
|
||
public List<int> PrepServiceIds { get; set; } = new();
|
||
|
||
// Pricing Breakdown
|
||
public QuotePricingBreakdownDto? PricingBreakdown { get; set; }
|
||
|
||
// Oven selection
|
||
public int? OvenCostId { get; set; }
|
||
public string? OvenLabel { get; set; }
|
||
|
||
// Oven batch pricing
|
||
public int OvenBatches { get; set; } = 1;
|
||
public int? OvenCycleMinutes { get; set; }
|
||
|
||
// Conversion Tracking
|
||
public int? ConvertedToJobId { get; set; }
|
||
public string? ConvertedToJobNumber { get; set; }
|
||
|
||
// Customer Approval Tracking
|
||
public string? ApprovalToken { get; set; }
|
||
public DateTime? ApprovalTokenExpiresAt { get; set; }
|
||
public DateTime? ApprovalTokenUsedAt { get; set; }
|
||
public string? DeclineReason { get; set; }
|
||
|
||
public bool IsProspect => !CustomerId.HasValue;
|
||
public bool IsExpired => ExpirationDate.HasValue && ExpirationDate.Value < DateTime.UtcNow && StatusIsDraft;
|
||
public string CustomerOrProspectName => CustomerName ?? ProspectCompanyName ?? ProspectContactName ?? "Unknown";
|
||
}
|
||
|
||
// ============================================================================
|
||
// CREATE QUOTE DTO - For creating new quotes
|
||
// ============================================================================
|
||
public class CreateQuoteDto
|
||
{
|
||
// Customer or Prospect Selection
|
||
public bool IsForProspect { get; set; }
|
||
|
||
[Display(Name = "Customer")]
|
||
public int? CustomerId { get; set; }
|
||
|
||
// Prospect Contact Information
|
||
[Display(Name = "Company Name")]
|
||
[StringLength(100)]
|
||
public string? ProspectCompanyName { get; set; }
|
||
|
||
[Display(Name = "Contact Name")]
|
||
[StringLength(100)]
|
||
public string? ProspectContactName { get; set; }
|
||
|
||
[Display(Name = "Email")]
|
||
[EmailAddress]
|
||
[StringLength(100)]
|
||
public string? ProspectEmail { get; set; }
|
||
|
||
[Display(Name = "Phone")]
|
||
[Phone]
|
||
[StringLength(20)]
|
||
public string? ProspectPhone { get; set; }
|
||
|
||
[Display(Name = "Address")]
|
||
[StringLength(200)]
|
||
public string? ProspectAddress { get; set; }
|
||
|
||
[Display(Name = "City")]
|
||
[StringLength(100)]
|
||
public string? ProspectCity { get; set; }
|
||
|
||
[Display(Name = "State")]
|
||
[StringLength(2)]
|
||
public string? ProspectState { get; set; }
|
||
|
||
[Display(Name = "Zip Code")]
|
||
[StringLength(10)]
|
||
public string? ProspectZipCode { get; set; }
|
||
|
||
[Display(Name = "SMS Consent")]
|
||
public bool ProspectSmsConsent { get; set; } = false;
|
||
|
||
// Oven Selection
|
||
[Display(Name = "Oven")]
|
||
public int? OvenCostId { get; set; }
|
||
|
||
// Oven Batch Pricing
|
||
[Display(Name = "Oven Batches")]
|
||
[Range(1, 9999)]
|
||
public int OvenBatches { get; set; } = 1;
|
||
|
||
[Display(Name = "Oven Cycle Time (min)")]
|
||
[Range(1, 1440)]
|
||
public int? OvenCycleMinutes { get; set; }
|
||
|
||
// Quote Information
|
||
[Display(Name = "Commercial Quote")]
|
||
public bool IsCommercial { get; set; }
|
||
|
||
[Display(Name = "Rush Job")]
|
||
public bool IsRushJob { get; set; }
|
||
|
||
[Display(Name = "Quote Date")]
|
||
[DataType(DataType.Date)]
|
||
public DateTime QuoteDate { get; set; } = DateTime.Today;
|
||
|
||
[Display(Name = "Expiration Date")]
|
||
[DataType(DataType.Date)]
|
||
public DateTime? ExpirationDate { get; set; }
|
||
|
||
[Display(Name = "Description")]
|
||
[StringLength(500)]
|
||
public string? Description { get; set; }
|
||
|
||
[Display(Name = "Terms & Conditions")]
|
||
[DataType(DataType.MultilineText)]
|
||
public string? Terms { get; set; }
|
||
|
||
[Display(Name = "Internal Notes")]
|
||
[DataType(DataType.MultilineText)]
|
||
public string? Notes { get; set; }
|
||
|
||
[Display(Name = "Customer PO Number")]
|
||
[StringLength(50)]
|
||
public string? CustomerPO { get; set; }
|
||
|
||
[Display(Name = "Tags")]
|
||
[StringLength(500)]
|
||
public string? Tags { get; set; }
|
||
|
||
// Pricing
|
||
[Display(Name = "Tax Percent (%)")]
|
||
[Range(0, 100)]
|
||
public decimal TaxPercent { get; set; }
|
||
|
||
// Discount
|
||
[Display(Name = "Discount Type")]
|
||
public string DiscountType { get; set; } = "None"; // None, Percentage, FixedAmount
|
||
|
||
[Display(Name = "Discount Value")]
|
||
[Range(0, 999999)]
|
||
public decimal DiscountValue { get; set; }
|
||
|
||
[Display(Name = "Discount Reason")]
|
||
[StringLength(200)]
|
||
public string? DiscountReason { get; set; }
|
||
|
||
[Display(Name = "Hide discount from customer")]
|
||
public bool HideDiscountFromCustomer { get; set; } = false;
|
||
|
||
// Items
|
||
[Required]
|
||
// Note: MinLength validation removed to prevent false positives on initial page load
|
||
// JavaScript validation handles this check on form submission
|
||
public List<CreateQuoteItemDto> QuoteItems { get; set; } = new();
|
||
|
||
// Prep Services
|
||
[Display(Name = "Preparation Services")]
|
||
public List<int> PrepServiceIds { get; set; } = new();
|
||
|
||
// Email Options
|
||
[Display(Name = "Send quote via email")]
|
||
public bool SendEmailToCustomer { get; set; } = false;
|
||
|
||
// AI Photo TempIds — list of uploaded temp photo IDs to promote on save
|
||
public List<string> AiPhotoTempIds { get; set; } = new();
|
||
|
||
// Quote Photo TempIds — general (non-AI) photos staged on the Create page
|
||
public List<string> QuotePhotoTempIds { get; set; } = new();
|
||
public List<string> QuotePhotoFileNames { get; set; } = new();
|
||
}
|
||
|
||
// ============================================================================
|
||
// UPDATE QUOTE DTO - For editing existing quotes
|
||
// ============================================================================
|
||
public class UpdateQuoteDto
|
||
{
|
||
public int Id { get; set; }
|
||
|
||
// Customer or Prospect Info (read-only after creation, but included for completeness)
|
||
public int? CustomerId { get; set; }
|
||
public bool IsForProspect { get; set; }
|
||
|
||
// Prospect Contact Information
|
||
[Display(Name = "Company Name")]
|
||
[StringLength(100)]
|
||
public string? ProspectCompanyName { get; set; }
|
||
|
||
[Display(Name = "Contact Name")]
|
||
[StringLength(100)]
|
||
public string? ProspectContactName { get; set; }
|
||
|
||
[Display(Name = "Email")]
|
||
[EmailAddress]
|
||
[StringLength(100)]
|
||
public string? ProspectEmail { get; set; }
|
||
|
||
[Display(Name = "Phone")]
|
||
[Phone]
|
||
[StringLength(20)]
|
||
public string? ProspectPhone { get; set; }
|
||
|
||
[Display(Name = "Address")]
|
||
[StringLength(200)]
|
||
public string? ProspectAddress { get; set; }
|
||
|
||
[Display(Name = "City")]
|
||
[StringLength(100)]
|
||
public string? ProspectCity { get; set; }
|
||
|
||
[Display(Name = "State")]
|
||
[StringLength(2)]
|
||
public string? ProspectState { get; set; }
|
||
|
||
[Display(Name = "Zip Code")]
|
||
[StringLength(10)]
|
||
public string? ProspectZipCode { get; set; }
|
||
|
||
[Display(Name = "SMS Consent")]
|
||
public bool ProspectSmsConsent { get; set; } = false;
|
||
|
||
// Oven Selection
|
||
[Display(Name = "Oven")]
|
||
public int? OvenCostId { get; set; }
|
||
|
||
// Oven Batch Pricing
|
||
[Display(Name = "Oven Batches")]
|
||
[Range(1, 9999)]
|
||
public int OvenBatches { get; set; } = 1;
|
||
|
||
[Display(Name = "Oven Cycle Time (min)")]
|
||
[Range(1, 1440)]
|
||
public int? OvenCycleMinutes { get; set; }
|
||
|
||
// Quote Information
|
||
[Display(Name = "Status")]
|
||
public int QuoteStatusId { get; set; } // FK to lookup table
|
||
|
||
[Display(Name = "Commercial Quote")]
|
||
public bool IsCommercial { get; set; }
|
||
|
||
[Display(Name = "Rush Job")]
|
||
public bool IsRushJob { get; set; }
|
||
|
||
[Display(Name = "Quote Date")]
|
||
[DataType(DataType.Date)]
|
||
public DateTime QuoteDate { get; set; }
|
||
|
||
[Display(Name = "Expiration Date")]
|
||
[DataType(DataType.Date)]
|
||
public DateTime? ExpirationDate { get; set; }
|
||
|
||
[Display(Name = "Description")]
|
||
[StringLength(500)]
|
||
public string? Description { get; set; }
|
||
|
||
[Display(Name = "Terms & Conditions")]
|
||
[DataType(DataType.MultilineText)]
|
||
public string? Terms { get; set; }
|
||
|
||
[Display(Name = "Internal Notes")]
|
||
[DataType(DataType.MultilineText)]
|
||
public string? Notes { get; set; }
|
||
|
||
[Display(Name = "Customer PO Number")]
|
||
[StringLength(50)]
|
||
public string? CustomerPO { get; set; }
|
||
|
||
[Display(Name = "Tags")]
|
||
[StringLength(500)]
|
||
public string? Tags { get; set; }
|
||
|
||
// Pricing
|
||
[Display(Name = "Tax Percent (%)")]
|
||
[Range(0, 100)]
|
||
public decimal TaxPercent { get; set; }
|
||
|
||
// Discount
|
||
[Display(Name = "Discount Type")]
|
||
public string DiscountType { get; set; } = "None"; // None, Percentage, FixedAmount
|
||
|
||
[Display(Name = "Discount Value")]
|
||
[Range(0, 999999)]
|
||
public decimal DiscountValue { get; set; }
|
||
|
||
[Display(Name = "Discount Reason")]
|
||
[StringLength(200)]
|
||
public string? DiscountReason { get; set; }
|
||
|
||
[Display(Name = "Hide discount from customer")]
|
||
public bool HideDiscountFromCustomer { get; set; } = false;
|
||
|
||
// Items
|
||
[Required]
|
||
// Note: MinLength validation removed to prevent false positives on initial page load
|
||
// JavaScript validation handles this check on form submission
|
||
public List<CreateQuoteItemDto> QuoteItems { get; set; } = new();
|
||
|
||
// Prep Services
|
||
[Display(Name = "Preparation Services")]
|
||
public List<int> PrepServiceIds { get; set; } = new();
|
||
|
||
// AI Photo TempIds — list of uploaded temp photo IDs to promote on save
|
||
public List<string> AiPhotoTempIds { get; set; } = new();
|
||
|
||
// Quote Photo TempIds — general (non-AI) photos staged on the Edit page (not used by direct-upload Edit path,
|
||
// kept for symmetry / future use)
|
||
public List<string> QuotePhotoTempIds { get; set; } = new();
|
||
public List<string> QuotePhotoFileNames { get; set; } = new();
|
||
}
|
||
|
||
// ============================================================================
|
||
// QUOTE ITEM DTO - For displaying quote items
|
||
// ============================================================================
|
||
public class QuoteItemDto
|
||
{
|
||
public int Id { get; set; }
|
||
public int QuoteId { get; set; }
|
||
|
||
[Display(Name = "Description")]
|
||
public string Description { get; set; } = string.Empty;
|
||
|
||
[Display(Name = "Quantity")]
|
||
public decimal Quantity { get; set; }
|
||
|
||
[Display(Name = "Surface Area (sq ft)")]
|
||
public decimal SurfaceAreaSqFt { get; set; }
|
||
|
||
[Display(Name = "Estimated Minutes")]
|
||
public int EstimatedMinutes { get; set; }
|
||
|
||
[Display(Name = "Requires Sandblasting")]
|
||
public bool RequiresSandblasting { get; set; }
|
||
|
||
[Display(Name = "Requires Masking")]
|
||
public bool RequiresMasking { get; set; }
|
||
|
||
[Display(Name = "Catalog Item")]
|
||
public int? CatalogItemId { get; set; }
|
||
public string? CatalogItemName { get; set; }
|
||
|
||
[Display(Name = "Unit Price")]
|
||
public decimal UnitPrice { get; set; }
|
||
|
||
[Display(Name = "Total Price")]
|
||
public decimal TotalPrice { get; set; }
|
||
|
||
public string? Notes { get; set; }
|
||
|
||
public bool IsGenericItem { get; set; }
|
||
public bool IsLaborItem { get; set; }
|
||
public bool IsSalesItem { get; set; }
|
||
public string? Sku { get; set; }
|
||
public decimal? ManualUnitPrice { get; set; }
|
||
|
||
// Coating layers
|
||
public List<QuoteItemCoatDto> Coats { get; set; } = new();
|
||
|
||
// Preparation services
|
||
public List<CreateQuoteItemPrepServiceDto> PrepServices { get; set; } = new();
|
||
|
||
// Part complexity level for calculated items
|
||
public string? Complexity { get; set; }
|
||
|
||
public bool IsAiItem { get; set; }
|
||
|
||
// Cost breakdown snapshot
|
||
public decimal ItemMaterialCost { get; set; }
|
||
public decimal ItemLaborCost { get; set; }
|
||
public decimal ItemEquipmentCost { get; set; }
|
||
}
|
||
|
||
// ============================================================================
|
||
// CREATE QUOTE ITEM DTO - For creating quote items
|
||
// ============================================================================
|
||
public class CreateQuoteItemDto
|
||
{
|
||
// Description is required for calculated items but auto-populated for catalog items
|
||
[Display(Name = "Description")]
|
||
[StringLength(200)]
|
||
public string Description { get; set; } = string.Empty;
|
||
|
||
[Required]
|
||
[Display(Name = "Quantity")]
|
||
[Range(0.01, 10000)]
|
||
public decimal Quantity { get; set; } = 1;
|
||
|
||
[Required]
|
||
[Display(Name = "Surface Area (sq ft)")]
|
||
[Range(0, 100000)] // 0 is valid for generic items
|
||
public decimal SurfaceAreaSqFt { get; set; }
|
||
|
||
[Required]
|
||
[Display(Name = "Estimated Minutes")]
|
||
[Range(0, 10000, ErrorMessage = "Estimated minutes must be between 0 and 10,000 (0 for catalog items)")]
|
||
public int EstimatedMinutes { get; set; }
|
||
|
||
[Display(Name = "Requires Sandblasting")]
|
||
public bool RequiresSandblasting { get; set; }
|
||
|
||
[Display(Name = "Requires Masking")]
|
||
public bool RequiresMasking { get; set; }
|
||
|
||
[Display(Name = "Catalog Item")]
|
||
public int? CatalogItemId { get; set; }
|
||
|
||
[Display(Name = "Notes")]
|
||
[StringLength(500)]
|
||
public string? Notes { get; set; }
|
||
|
||
// Generic item — price entered manually, bypasses calculation engine
|
||
public bool IsGenericItem { get; set; }
|
||
|
||
[Range(0, 1000000)]
|
||
public decimal? ManualUnitPrice { get; set; }
|
||
|
||
// Labor item — Quantity = hours; priced using StandardLaborRate × markup, no coats
|
||
public bool IsLaborItem { get; set; }
|
||
|
||
// Sales item — off-the-shelf merchandise (T-shirts, tumblers, etc.)
|
||
public bool IsSalesItem { get; set; }
|
||
public string? Sku { get; set; }
|
||
|
||
// Coating layers
|
||
public List<CreateQuoteItemCoatDto> Coats { get; set; } = new();
|
||
|
||
// Prep services (each has its own time estimate)
|
||
// For catalog items, prep cost is only included when IncludePrepCost = true
|
||
public List<CreateQuoteItemPrepServiceDto> PrepServices { get; set; } = new();
|
||
|
||
// Controls whether prep service labor cost is added to the item price.
|
||
// Always true for calculated items; catalog items default to false (costs baked in)
|
||
// but can be opted in via the wizard toggle.
|
||
public bool IncludePrepCost { get; set; } = true;
|
||
|
||
// Part complexity — drives a price multiplier on calculated items only
|
||
// Values: null | "Simple" | "Moderate" | "Complex" | "Extreme"
|
||
public string? Complexity { get; set; }
|
||
|
||
// Optional unit price override for catalog items — overrides the catalog default price
|
||
public decimal? PowderCostOverride { get; set; }
|
||
|
||
// True when this item was generated via AI photo analysis
|
||
public bool IsAiItem { get; set; }
|
||
|
||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||
public string? AiTags { get; set; }
|
||
|
||
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
||
public int? AiPredictionId { get; set; }
|
||
}
|
||
|
||
// ============================================================================
|
||
// QUOTE ITEM PREP SERVICE DTO
|
||
// ============================================================================
|
||
public class CreateQuoteItemPrepServiceDto
|
||
{
|
||
[Required]
|
||
public int PrepServiceId { get; set; }
|
||
|
||
[Required]
|
||
[Range(0, 10000)]
|
||
public int EstimatedMinutes { get; set; }
|
||
|
||
/// <summary>Blast setup selected in wizard for this sandblasting prep service.</summary>
|
||
public int? BlastSetupId { get; set; }
|
||
|
||
// Populated when mapping from entity (for display purposes)
|
||
public string? PrepServiceName { get; set; }
|
||
}
|
||
|
||
// ============================================================================
|
||
// PRICING BREAKDOWN DTO - For transparent cost breakdown
|
||
// ============================================================================
|
||
public class QuotePricingBreakdownDto
|
||
{
|
||
// Per-Item Costs
|
||
public decimal MaterialCosts { get; set; }
|
||
public decimal LaborCosts { get; set; }
|
||
public decimal EquipmentCosts { get; set; }
|
||
public decimal ItemSubtotal { get; set; }
|
||
|
||
// Quote-Level Costs
|
||
public decimal ItemsSubtotal { get; set; }
|
||
public decimal ShopSuppliesAmount { get; set; }
|
||
public decimal ShopSuppliesPercent { get; set; }
|
||
|
||
public decimal OverheadCosts { get; set; }
|
||
public decimal OverheadPercent { get; set; }
|
||
|
||
public decimal ProfitMargin { get; set; }
|
||
public decimal ProfitPercent { get; set; }
|
||
|
||
public decimal SubtotalBeforeDiscount { get; set; }
|
||
|
||
public decimal PricingTierDiscountAmount { get; set; }
|
||
public decimal PricingTierDiscountPercent { get; set; }
|
||
public decimal QuoteDiscountAmount { get; set; }
|
||
public decimal QuoteDiscountPercent { get; set; }
|
||
|
||
public decimal DiscountAmount { get; set; }
|
||
public decimal DiscountPercent { get; set; }
|
||
|
||
public decimal RushFee { get; set; }
|
||
|
||
public decimal SubtotalAfterDiscount { get; set; }
|
||
|
||
public decimal TaxAmount { get; set; }
|
||
public decimal TaxPercent { get; set; }
|
||
|
||
public decimal OvenBatchCost { get; set; }
|
||
public int OvenBatches { get; set; }
|
||
public int OvenCycleMinutes { get; set; }
|
||
|
||
public decimal FacilityOverheadCost { get; set; }
|
||
public decimal FacilityOverheadRatePerHour { get; set; }
|
||
|
||
public decimal Total { get; set; }
|
||
|
||
// Cost Breakdown Details
|
||
public string CostBreakdownDetails { get; set; } = string.Empty;
|
||
}
|
||
|
||
// ============================================================================
|
||
// CONVERT TO CUSTOMER DTO - For prospect-to-customer conversion
|
||
// ============================================================================
|
||
public class ConvertQuoteToCustomerDto
|
||
{
|
||
public int QuoteId { get; set; }
|
||
public string QuoteNumber { get; set; } = string.Empty;
|
||
|
||
// Pre-filled from prospect data
|
||
[Display(Name = "Company Name")]
|
||
[StringLength(100)]
|
||
public string? CompanyName { get; set; }
|
||
|
||
[Required]
|
||
[Display(Name = "Contact Name")]
|
||
[StringLength(100)]
|
||
public string ContactName { get; set; } = string.Empty;
|
||
|
||
[Display(Name = "Email")]
|
||
[EmailAddress]
|
||
[StringLength(100)]
|
||
public string? Email { get; set; }
|
||
|
||
[Display(Name = "Phone")]
|
||
[Phone]
|
||
[StringLength(20)]
|
||
public string? Phone { get; set; }
|
||
|
||
[Display(Name = "Address")]
|
||
[StringLength(200)]
|
||
public string? Address { get; set; }
|
||
|
||
[Display(Name = "City")]
|
||
[StringLength(100)]
|
||
public string? City { get; set; }
|
||
|
||
[Display(Name = "State")]
|
||
[StringLength(2)]
|
||
public string? State { get; set; }
|
||
|
||
[Display(Name = "Zip Code")]
|
||
[StringLength(10)]
|
||
public string? ZipCode { get; set; }
|
||
|
||
// Additional customer fields
|
||
[Display(Name = "Tax ID / EIN")]
|
||
[StringLength(50)]
|
||
public string? TaxId { get; set; }
|
||
|
||
[Display(Name = "Credit Limit")]
|
||
[Range(0, 1000000)]
|
||
public decimal CreditLimit { get; set; }
|
||
|
||
[Display(Name = "Pricing Tier")]
|
||
public int? PricingTierId { get; set; }
|
||
|
||
[Display(Name = "Customer Type")]
|
||
public bool IsCommercial { get; set; } = true;
|
||
|
||
[Display(Name = "Payment Terms")]
|
||
[StringLength(100)]
|
||
public string? PaymentTerms { get; set; }
|
||
|
||
[Display(Name = "Notes")]
|
||
[DataType(DataType.MultilineText)]
|
||
public string? Notes { get; set; }
|
||
|
||
/// <summary>
|
||
/// Staff must explicitly confirm verbal SMS consent before it carries over to the new customer record.
|
||
/// Pre-checked when ProspectSmsConsent was true on the source quote.
|
||
/// </summary>
|
||
[Display(Name = "Customer has given verbal consent to receive SMS notifications")]
|
||
public bool SmsConsent { get; set; }
|
||
|
||
/// <summary>Timestamp from the source quote — preserved so the consent record reflects when consent was originally given.</summary>
|
||
public DateTime? ProspectSmsConsentedAt { get; set; }
|
||
}
|
||
|
||
// ============================================================================
|
||
// QUOTE ITEM COAT DTOs - For multi-coat support
|
||
// ============================================================================
|
||
|
||
/// <summary>
|
||
/// DTO for creating/editing a coating layer on a quote item
|
||
/// </summary>
|
||
public class CreateQuoteItemCoatDto
|
||
{
|
||
[Required]
|
||
[StringLength(100)]
|
||
[Display(Name = "Coat Name")]
|
||
public string CoatName { get; set; } = "Base Coat";
|
||
|
||
public int Sequence { get; set; } = 1;
|
||
|
||
[Display(Name = "Inventory Powder")]
|
||
public int? InventoryItemId { get; set; }
|
||
|
||
[StringLength(100)]
|
||
[Display(Name = "Color Name")]
|
||
public string? ColorName { get; set; }
|
||
|
||
[Display(Name = "Vendor")]
|
||
public int? VendorId { get; set; }
|
||
|
||
[StringLength(50)]
|
||
[Display(Name = "Color Code")]
|
||
public string? ColorCode { get; set; }
|
||
|
||
[StringLength(50)]
|
||
[Display(Name = "Finish")]
|
||
public string? Finish { get; set; }
|
||
|
||
[Range(1, 500)]
|
||
[Display(Name = "Coverage (sq ft/lb)")]
|
||
public decimal CoverageSqFtPerLb { get; set; } = 30m;
|
||
|
||
[Range(1, 100)]
|
||
[Display(Name = "Transfer Efficiency (%)")]
|
||
public decimal TransferEfficiency { get; set; } = 65m;
|
||
|
||
[Range(0, 1000)]
|
||
[Display(Name = "Powder Cost ($/lb)")]
|
||
public decimal? PowderCostPerLb { get; set; }
|
||
|
||
[Range(0, 10000)]
|
||
[Display(Name = "Powder to Order (lbs)")]
|
||
public decimal? PowderToOrder { get; set; }
|
||
|
||
[StringLength(500)]
|
||
[Display(Name = "Notes")]
|
||
public string? Notes { get; set; }
|
||
|
||
/// <summary>
|
||
/// When true, the additional layer labor charge is not applied even if this is not the first coat.
|
||
/// </summary>
|
||
public bool NoExtraLayerCharge { get; set; }
|
||
|
||
/// <summary>Platform powder catalog item ID selected via the Custom tab lookup.</summary>
|
||
public int? CatalogItemId { get; set; }
|
||
|
||
/// <summary>
|
||
/// When true (and CatalogItemId is set), the server creates a 0-balance IsIncoming inventory
|
||
/// item from the catalog entry so QR codes can be printed while the powder is in transit.
|
||
/// The coat is then linked to that new inventory record.
|
||
/// </summary>
|
||
public bool AddAsIncoming { get; set; }
|
||
}
|
||
|
||
/// <summary>
|
||
/// DTO for displaying a coating layer on a quote item
|
||
/// </summary>
|
||
public class QuoteItemCoatDto
|
||
{
|
||
public int Id { get; set; }
|
||
public string CoatName { get; set; } = string.Empty;
|
||
public int Sequence { get; set; }
|
||
public int? InventoryItemId { get; set; }
|
||
public string? InventoryItemName { get; set; }
|
||
public int? VendorId { get; set; }
|
||
public string? VendorName { get; set; }
|
||
public string? ColorName { get; set; }
|
||
public string? ColorCode { get; set; }
|
||
public string? Finish { get; set; }
|
||
public decimal CoverageSqFtPerLb { get; set; }
|
||
public decimal TransferEfficiency { get; set; }
|
||
public decimal? PowderToOrder { get; set; }
|
||
public decimal CoatMaterialCost { get; set; }
|
||
public decimal CoatLaborCost { get; set; }
|
||
public decimal CoatTotalCost { get; set; }
|
||
public string? Notes { get; set; }
|
||
}
|
||
|
||
// ============================================================================
|
||
// PRICING CALCULATION RESULT - Internal use for pricing service
|
||
// ============================================================================
|
||
|
||
/// <summary>
|
||
/// Result of calculating costs for a single coating layer
|
||
/// </summary>
|
||
public class QuoteItemCoatPricingResult
|
||
{
|
||
public decimal CoatMaterialCost { get; set; }
|
||
public decimal CoatLaborCost { get; set; }
|
||
public decimal CoatTotalCost { get; set; }
|
||
}
|
||
|
||
public class QuoteItemPricingResult
|
||
{
|
||
public decimal MaterialCost { get; set; }
|
||
public decimal LaborCost { get; set; }
|
||
public decimal EquipmentCost { get; set; }
|
||
public decimal ItemSubtotal { get; set; }
|
||
public decimal UnitPrice { get; set; }
|
||
public decimal TotalPrice { get; set; }
|
||
}
|
||
|
||
public class QuotePricingResult
|
||
{
|
||
public decimal ItemsSubtotal { get; set; }
|
||
public decimal ShopSuppliesAmount { get; set; }
|
||
public decimal ShopSuppliesPercent { get; set; }
|
||
public decimal OverheadCosts { get; set; }
|
||
public decimal OverheadPercent { get; set; }
|
||
public decimal ProfitMargin { get; set; }
|
||
public decimal ProfitPercent { get; set; }
|
||
public decimal SubtotalBeforeDiscount { get; set; }
|
||
|
||
// Pricing Tier Discount (from customer's pricing tier)
|
||
public decimal PricingTierDiscountAmount { get; set; }
|
||
public decimal PricingTierDiscountPercent { get; set; }
|
||
|
||
// Quote-Level Discount (applied on the quote)
|
||
public decimal QuoteDiscountAmount { get; set; }
|
||
public decimal QuoteDiscountPercent { get; set; }
|
||
|
||
// Combined Discount (for backward compatibility)
|
||
public decimal DiscountAmount { get; set; }
|
||
public decimal DiscountPercent { get; set; }
|
||
|
||
public decimal SubtotalAfterDiscount { get; set; }
|
||
public decimal RushFee { get; set; }
|
||
public decimal TaxAmount { get; set; }
|
||
public decimal TaxPercent { get; set; }
|
||
public decimal Total { get; set; }
|
||
|
||
// Oven batch cost (quote-level, separate from per-item calculations)
|
||
public decimal OvenBatchCost { get; set; }
|
||
public int OvenBatches { get; set; }
|
||
public int OvenCycleMinutes { get; set; }
|
||
|
||
// Facility overhead (rent + utilities apportioned by estimated job hours)
|
||
public decimal FacilityOverheadCost { get; set; }
|
||
public decimal FacilityOverheadRatePerHour { get; set; }
|
||
|
||
// Detailed breakdown for transparency
|
||
public decimal MaterialCosts { get; set; }
|
||
public decimal LaborCosts { get; set; }
|
||
public decimal EquipmentCosts { get; set; }
|
||
|
||
// Per-item results (same order as input items)
|
||
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
||
}
|