Compare commits
96 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 | |||
| 4ec55e7290 | |||
| 3eda91f170 | |||
| cefdf3e35c | |||
| f34ee749be | |||
| 357ef84001 | |||
| 7a1a697dc2 | |||
| 539c6c2559 | |||
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd | |||
| 350f2d7658 | |||
| 856d202b78 | |||
| 8caaa84eac | |||
| e70f7ee9f1 | |||
| 6a918c2afc | |||
| 27bfd4db4d | |||
| 787d1504ef | |||
| 726bebdce9 | |||
| 786b78e502 | |||
| cb1b6dceb6 | |||
| fb31fa7eb3 | |||
| 637be701ea | |||
| e9cd67f5d9 | |||
| 433090effd | |||
| 4ca90f561e | |||
| f95397204c | |||
| 31d305b66a | |||
| 42a8c089d5 | |||
| 2c353f2e7f | |||
| c02a5584b4 | |||
| 17da692dce | |||
| 656f830898 | |||
| dde66c807f | |||
| feff0fa73d | |||
| 59beba2e15 | |||
| 959e323f3a | |||
| e2f9e9ae4f | |||
| 328b195127 | |||
| f6d457fe0e | |||
| c65445b94e | |||
| ccb094e57a | |||
| 0204430fa5 | |||
| 4fd9c52aaf | |||
| fde24b09c9 | |||
| a255893ada | |||
| d94612cc9c | |||
| 14026818e2 | |||
| 42eff3357e | |||
| d3a5d827f9 | |||
| 1229081436 | |||
| cf9dcfb4c1 | |||
| a33687f7bd | |||
| 0afb474c3e | |||
| 7e1676cfd7 | |||
| 379b0de885 | |||
| edd7389d7d | |||
| 61866e1d1e | |||
| bc9de38da3 | |||
| 2694863d07 | |||
| 8646fa83c8 | |||
| 796d084ea6 | |||
| 6d23c63912 | |||
| 3803d16731 | |||
| 29fd7163dc | |||
| 9a52e7fae5 | |||
| 0d980e651a | |||
| 3278152d83 | |||
| fc35fd123c | |||
| f40d58ac2e | |||
| fb979bc88d | |||
| 12f784f34c | |||
| 90f93b6e2f | |||
| 135fd6f8d7 | |||
| ff231d9dd2 |
@@ -172,7 +172,9 @@
|
||||
"Bash(Select-Object -First 20)",
|
||||
"PowerShell(node -e \"require\\('fs'\\).existsSync\\(require\\('path'\\).join\\(process.cwd\\(\\), 'node_modules', 'sharp'\\)\\) ? console.log\\('sharp ok'\\) : console.log\\('no sharp'\\)\")",
|
||||
"WebFetch(domain:www.powdercoatinglogix.com)",
|
||||
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)"
|
||||
"PowerShell($bytes = [System.IO.File]::ReadAllBytes\\('src/PowderCoating.Web/Views/Jobs/Details.cshtml'\\); $text = [System.Text.Encoding]::UTF8.GetString\\($bytes\\); $idx = $text.IndexOf\\('hasPowderData'\\); $snippet = $text.Substring\\($idx - 20, 250\\); [System.Text.Encoding]::Unicode.GetBytes\\($snippet\\) | Format-Hex | Select-Object -First 30)",
|
||||
"PowerShell($dll = \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\questpdf\\\\2024.12.3\\\\lib\\\\net6.0\\\\QuestPDF.dll\"; $asm = [Reflection.Assembly]::LoadFile\\($dll\\); $asm.GetTypes\\(\\) | Where-Object { $_.Name -eq \"ContainerExtensions\" } | ForEach-Object { $_.GetMethods\\(\\) | Where-Object { $_.Name -match \"Canvas|Rotat|Layer\" } | Select-Object Name } | Sort-Object Name -Unique)",
|
||||
"PowerShell(Get-ChildItem \"C:\\\\Users\\\\spoul\\\\.nuget\\\\packages\\\\\" -ErrorAction SilentlyContinue | Where-Object { $_.Name -match \"quest|skia\" } | Select-Object Name)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
|
||||
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
|
||||
|
||||
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
||||
|
||||
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
|
||||
|
||||
| Flag | Effect if missing on JobItem |
|
||||
|------|------------------------------|
|
||||
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||
|
||||
**Checklist when adding a new pricing routing flag:**
|
||||
1. Add the property to `QuoteItem` (Core/Entities)
|
||||
2. Add the property to `JobItem` (Core/Entities)
|
||||
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||
7. Add a migration if the field is new on a persisted entity
|
||||
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is intentional
|
||||
|
||||
### Branding
|
||||
- Application name: **Powder Coating Logix**
|
||||
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
|
||||
|
||||
Vendored
+49
-1
@@ -24,6 +24,17 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
steps {
|
||||
bat 'dotnet test tests\\PowderCoating.UnitTests --no-build -c Release --logger "trx;LogFileName=results.trx" --results-directory TestResults'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit testResults: 'TestResults/*.trx', allowEmptyResults: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Migrations') {
|
||||
steps {
|
||||
bat 'dotnet tool install --global dotnet-ef 2>nul || dotnet tool update --global dotnet-ef 2>nul'
|
||||
@@ -42,7 +53,18 @@ pipeline {
|
||||
|
||||
stage('Deploy to Azure') {
|
||||
steps {
|
||||
bat 'powershell -Command "Add-Type -Assembly System.IO.Compression.FileSystem; if (Test-Path deploy.zip) { Remove-Item deploy.zip }; [System.IO.Compression.ZipFile]::CreateFromDirectory(\'publish\', \'deploy.zip\')"'
|
||||
powershell '''
|
||||
Add-Type -Assembly System.IO.Compression.FileSystem
|
||||
if (Test-Path deploy.zip) { Remove-Item deploy.zip }
|
||||
$publishDir = (Resolve-Path "publish").Path
|
||||
$zip = [System.IO.Compression.ZipFile]::Open("deploy.zip", "Create")
|
||||
Get-ChildItem -Path $publishDir -Recurse -File | ForEach-Object {
|
||||
$entryName = $_.FullName.Substring($publishDir.Length + 1).Replace("\\", "/")
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, $entryName, "Optimal") | Out-Null
|
||||
}
|
||||
$zip.Dispose()
|
||||
Write-Host "deploy.zip created with forward-slash entry paths"
|
||||
'''
|
||||
withCredentials([azureServicePrincipal(
|
||||
credentialsId: 'azure-pcl',
|
||||
subscriptionIdVariable: 'AZ_SUB_ID',
|
||||
@@ -57,6 +79,32 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Health Check') {
|
||||
steps {
|
||||
powershell '''
|
||||
$url = "https://app.powdercoatinglogix.com/"
|
||||
$timeout = 180
|
||||
$elapsed = 0
|
||||
Write-Host "Polling $url for up to $timeout seconds..."
|
||||
do {
|
||||
Start-Sleep -Seconds 10
|
||||
$elapsed += 10
|
||||
try {
|
||||
$r = Invoke-WebRequest $url -UseBasicParsing -TimeoutSec 10
|
||||
if ($r.StatusCode -lt 400) {
|
||||
Write-Host "App responded HTTP $($r.StatusCode) after ${elapsed}s"
|
||||
exit 0
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[${elapsed}s] Not yet responding: $_"
|
||||
}
|
||||
} while ($elapsed -lt $timeout)
|
||||
Write-Error "App did not come healthy within $timeout seconds"
|
||||
exit 1
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
|
||||
-Add feature to prep for events where we can generate coupons or gift certificates in bulk
|
||||
|
||||
Duplication refactor memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md.
|
||||
|
||||
Current memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md
|
||||
|
||||
|
||||
|
||||
-Google review request email after a job
|
||||
@@ -187,6 +191,29 @@ AI Agent item where we upload a picture and it will calculate the approximate sq
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
|
||||
|
||||
5/7/2026
|
||||
-When editing a job/quote item from catalog, pre-select the item chosen please
|
||||
-Move buttons to right side of job details page
|
||||
-When completing a job, pull in powder usage already entered
|
||||
-Fix invoice due date to match terms selected
|
||||
-Invoice Status should not show on PDF unless PAID
|
||||
-If we start with a job, shop supplies is not being added to the items
|
||||
-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it
|
||||
-Customer approval page doesn't show all charges (Oven time missing?)
|
||||
-Time Logging default user to logged in user
|
||||
-Add Print Invoice button or allow viewing the PDF
|
||||
-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one.
|
||||
-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created
|
||||
-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes)
|
||||
-Support entering multiple email addresses (comma seperated) in each field
|
||||
-If no email on file, then prompt for address to send to.
|
||||
-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list?
|
||||
-When choosing a prospect for a quote, we need way to consent and enable SMS for them
|
||||
|
||||
Ideas Removed
|
||||
=======================
|
||||
|
||||
+32
-3
@@ -1,8 +1,33 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-When editing a job/quote item from catalog, pre-select the item chosen please
|
||||
-Move buttons to right side of job details page
|
||||
-When completing a job, pull in powder usage already entered
|
||||
-Fix invoice due date to match terms selected
|
||||
-Invoice Status should not show on PDF unless PAID
|
||||
-If we start with a job, shop supplies is not being added to the items
|
||||
-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it
|
||||
-Customer approval page doesn't show all charges (Oven time missing?)
|
||||
-Time Logging default user to logged in user
|
||||
-Add Print Invoice button or allow viewing the PDF
|
||||
-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one.
|
||||
-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created
|
||||
-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes)
|
||||
-Support entering multiple email addresses (comma seperated) in each field
|
||||
-If no email on file, then prompt for address to send to.
|
||||
-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list?
|
||||
-When choosing a prospect for a quote, we need way to consent and enable SMS for them
|
||||
|
||||
|
||||
|
||||
|
||||
Duplication refactor memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md.
|
||||
|
||||
Current memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md
|
||||
|
||||
|
||||
|
||||
-Google review request email after a job
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
@@ -185,6 +210,10 @@ AI Agent item where we upload a picture and it will calculate the approximate sq
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
|
||||
|
||||
Ideas Removed
|
||||
=======================
|
||||
|
||||
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
|
||||
public string? RecommendedAction { get; set; }
|
||||
public string? BillNumber { get; set; }
|
||||
}
|
||||
|
||||
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
|
||||
|
||||
public class BankRecMatchItem
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
|
||||
public int EntityId { get; set; }
|
||||
public string Date { get; set; } = string.Empty; // ISO 8601
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
|
||||
}
|
||||
|
||||
public class AutoMatchRequest
|
||||
{
|
||||
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal StatementEndingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class AutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; } // 0.0–1.0
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AutoMatchResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
|
||||
public class ClaudeAutoMatchResponse
|
||||
{
|
||||
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeAutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
|
||||
|
||||
public class OpenInvoiceSummary
|
||||
{
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public decimal BalanceDue { get; set; }
|
||||
public string? DueDateIso { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
public class LatePaymentCustomerData
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public decimal TotalOwed { get; set; }
|
||||
public double AvgDaysToPay { get; set; } // historical average
|
||||
public int TotalInvoicesAllTime { get; set; }
|
||||
public int LateInvoicesAllTime { get; set; }
|
||||
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<LatePaymentCustomerData> Customers { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<LatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
|
||||
public class ClaudeLatePaymentResponse
|
||||
{
|
||||
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeLatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
|
||||
|
||||
public class MonthlyFinancialSummary
|
||||
{
|
||||
public string Month { get; set; } = string.Empty; // "YYYY-MM"
|
||||
public decimal Revenue { get; set; }
|
||||
public decimal Expenses { get; set; }
|
||||
public decimal NetIncome { get; set; }
|
||||
}
|
||||
|
||||
public class FinancialQueryContext
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string AsOfDate { get; set; } = string.Empty;
|
||||
public decimal TotalRevenueYtd { get; set; }
|
||||
public decimal TotalExpensesYtd { get; set; }
|
||||
public decimal NetIncomeYtd { get; set; }
|
||||
public decimal ArOutstanding { get; set; }
|
||||
public decimal ApOutstanding { get; set; }
|
||||
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
|
||||
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryRequest
|
||||
{
|
||||
public string Question { get; set; } = string.Empty;
|
||||
public FinancialQueryContext Context { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
|
||||
public class ClaudeFinancialQueryResponse
|
||||
{
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
|
||||
|
||||
public class RecurringBillHistoryItem
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string DateIso { get; set; } = string.Empty;
|
||||
public string? Memo { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RecurringBillPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<RecurringBillPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
|
||||
public class ClaudeRecurringBillResponse
|
||||
{
|
||||
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeRecurringPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,158 @@ using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Accounting;
|
||||
|
||||
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
|
||||
// without needing a separate round-trip to the company settings.
|
||||
|
||||
|
||||
// ── Cash Flow Statement ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cash Flow Statement using the direct (cash-basis) method for operating activities.
|
||||
/// Investing and Financing sections contain line items derived from account-level changes.
|
||||
/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances).
|
||||
/// </summary>
|
||||
public class CashFlowStatementDto
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public AccountingMethod Method { get; set; }
|
||||
|
||||
// ── Operating (direct / cash method) ───────────────────────────────────
|
||||
/// <summary>Customer invoice payments received in the period.</summary>
|
||||
public decimal CashFromCustomers { get; set; }
|
||||
/// <summary>Vendor bill payments made in the period.</summary>
|
||||
public decimal CashToVendors { get; set; }
|
||||
/// <summary>Direct expense payments made in the period (not via bills).</summary>
|
||||
public decimal CashForExpenses { get; set; }
|
||||
public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses;
|
||||
|
||||
// ── Investing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> InvestingLines { get; set; } = new();
|
||||
public decimal NetInvesting => InvestingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Financing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> FinancingLines { get; set; } = new();
|
||||
public decimal NetFinancing => FinancingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────
|
||||
public decimal BeginningCash { get; set; }
|
||||
public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing;
|
||||
public decimal EndingCash => BeginningCash + NetChangeInCash;
|
||||
}
|
||||
|
||||
/// <summary>A single line in the Investing or Financing section of the Cash Flow Statement.</summary>
|
||||
public class CashFlowLineDto
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>Positive = cash inflow, negative = cash outflow.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
// ── Customer / Vendor Statements ─────────────────────────────────────────────
|
||||
|
||||
public class CustomerStatementDto
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CustomerAddress { get; set; }
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class VendorStatementDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class StatementLineDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
|
||||
public decimal? Debit { get; set; }
|
||||
/// <summary>Amount reducing the balance (payment, credit).</summary>
|
||||
public decimal? Credit { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ApAgingReportDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
public List<ApAgingVendorDto> Vendors { get; set; } = new();
|
||||
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingVendorDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public List<ApAgingBillDto> Bills { get; set; } = new();
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingBillDto
|
||||
{
|
||||
public int BillId { get; set; }
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public DateTime BillDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
// ── Trial Balance ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class TrialBalanceDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<TrialBalanceLine> Lines { get; set; } = new();
|
||||
public decimal TotalDebits { get; set; }
|
||||
public decimal TotalCredits { get; set; }
|
||||
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
|
||||
}
|
||||
|
||||
public class TrialBalanceLine
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
public decimal DebitBalance { get; set; }
|
||||
public decimal CreditBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class ProfitAndLossDto
|
||||
@@ -9,6 +161,7 @@ public class ProfitAndLossDto
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
public List<FinancialReportLine> RevenueLines { get; set; } = new();
|
||||
public decimal TotalRevenue { get; set; }
|
||||
@@ -40,6 +193,7 @@ public class BalanceSheetDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
// Assets
|
||||
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
|
||||
|
||||
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
|
||||
public class PagedResult<T>
|
||||
{
|
||||
public IEnumerable<T> Items { get; set; } = new List<T>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PagedResult populated from a GridRequest, avoiding repetitive property
|
||||
/// assignments across every Index action. SortColumn, SortDirection, and SearchTerm
|
||||
/// are copied from the grid so the model carries full state for view binding.
|
||||
/// </summary>
|
||||
public static PagedResult<T> From(GridRequest grid, IEnumerable<T> items, int totalCount) => new()
|
||||
{
|
||||
Items = items,
|
||||
PageNumber = grid.PageNumber,
|
||||
PageSize = grid.PageSize,
|
||||
TotalCount = totalCount,
|
||||
SortColumn = grid.SortColumn,
|
||||
SortDirection = grid.SortDirection,
|
||||
SearchTerm = grid.SearchTerm
|
||||
};
|
||||
public int PageNumber { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
||||
public bool WizardCompleted { get; set; }
|
||||
public DateTime? WizardCompletedAt { get; set; }
|
||||
public string? WizardCompletedByName { get; set; }
|
||||
|
||||
// Health signals — populated by CompaniesController.Index after the count summary query
|
||||
public int HealthScore { get; set; }
|
||||
public string HealthRisk { get; set; } = "Healthy";
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
||||
// Blank Work Order PDF Template
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
public string? WoTerms { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
public class UpdateAppDefaultsDto
|
||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class UpdateKioskSettingsDto
|
||||
{
|
||||
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||
[Required]
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
public string? State { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? TimeZone { get; set; }
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
public bool HasLogo { get; set; }
|
||||
|
||||
public CompanyOperatingCostsDto? OperatingCosts { get; set; }
|
||||
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
|
||||
public string? TimeZone { get; set; }
|
||||
|
||||
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -9,6 +9,7 @@ public class CustomerDto
|
||||
public string? ContactFirstName { get; set; }
|
||||
public string? ContactLastName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? BillingEmail { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
@@ -52,10 +53,13 @@ public class CreateCustomerDto : IValidatableObject
|
||||
public string? ContactLastName { get; set; }
|
||||
|
||||
[Display(Name = "Email")]
|
||||
[EmailAddress(ErrorMessage = "Please enter a valid email address")]
|
||||
[StringLength(200)]
|
||||
[StringLength(1000)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Display(Name = "Billing / Accounting Email")]
|
||||
[StringLength(1000)]
|
||||
public string? BillingEmail { get; set; }
|
||||
|
||||
[Display(Name = "Phone")]
|
||||
[Phone(ErrorMessage = "Please enter a valid phone number")]
|
||||
[StringLength(20)]
|
||||
@@ -136,13 +140,40 @@ public class CreateCustomerDto : IValidatableObject
|
||||
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
||||
}
|
||||
|
||||
// At least one contact method is required (Email OR Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
|
||||
// At least one contact method is required (Email, Phone, or Mobile Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Please provide at least one contact method (Email or Phone)",
|
||||
new[] { nameof(Email), nameof(Phone) });
|
||||
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
|
||||
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
|
||||
}
|
||||
|
||||
// Validate each address in comma-separated email fields
|
||||
foreach (var addr in SplitEmails(Email))
|
||||
{
|
||||
if (!IsValidEmail(addr))
|
||||
yield return new ValidationResult(
|
||||
$"'{addr}' is not a valid email address.",
|
||||
new[] { nameof(Email) });
|
||||
}
|
||||
foreach (var addr in SplitEmails(BillingEmail))
|
||||
{
|
||||
if (!IsValidEmail(addr))
|
||||
yield return new ValidationResult(
|
||||
$"'{addr}' is not a valid email address.",
|
||||
new[] { nameof(BillingEmail) });
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitEmails(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? []
|
||||
: value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
private static bool IsValidEmail(string email)
|
||||
{
|
||||
try { _ = new System.Net.Mail.MailAddress(email); return true; }
|
||||
catch { return false; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
||||
public GiftCertificateStatus Status { get; set; }
|
||||
public DateTime IssueDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
}
|
||||
|
||||
public class GiftCertificateDto : GiftCertificateListDto
|
||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
||||
[Range(0.01, 9999.99)]
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateGiftCertificateDto
|
||||
{
|
||||
[Required]
|
||||
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||
[Display(Name = "Number of Certificates")]
|
||||
public int Quantity { get; set; } = 25;
|
||||
|
||||
[Required]
|
||||
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||
[Display(Name = "Face Value (each)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required]
|
||||
[Display(Name = "Issued Reason")]
|
||||
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||
|
||||
[Display(Name = "Expiry Date (optional)")]
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ public class InventoryItemDto
|
||||
public string? Location { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsIncoming { get; set; }
|
||||
public DateTime? DiscontinuedDate { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public bool IsLowStock { get; set; }
|
||||
@@ -74,6 +75,7 @@ public class InventoryListDto
|
||||
public int? PrimaryVendorId { get; set; }
|
||||
public string? PrimaryVendorName { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public bool IsIncoming { get; set; }
|
||||
public bool IsLowStock { get; set; }
|
||||
public bool IsOutOfStock { get; set; }
|
||||
public bool HasSamplePanel { get; set; }
|
||||
@@ -217,6 +219,9 @@ public class CreateInventoryItemDto
|
||||
[Display(Name = "Sample Panel on Wall")]
|
||||
public bool HasSamplePanel { get; set; }
|
||||
|
||||
[Display(Name = "Incoming / On Order")]
|
||||
public bool IsIncoming { get; set; }
|
||||
|
||||
}
|
||||
|
||||
public class UpdateInventoryItemDto : CreateInventoryItemDto
|
||||
|
||||
@@ -32,7 +32,9 @@ public class InvoiceDto
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string? CustomerEmail { get; set; }
|
||||
public string? CustomerPhone { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; }
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? PreparedById { get; set; }
|
||||
public string? PreparedByName { get; set; }
|
||||
public InvoiceStatus Status { get; set; }
|
||||
@@ -82,6 +84,10 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { 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>
|
||||
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>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||
public PaymentMethod RefundMethod { get; set; }
|
||||
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string? Reference { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
@@ -45,6 +45,12 @@ public class JobDto
|
||||
|
||||
public decimal QuotedPrice { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
public decimal ShopSuppliesAmount { get; set; }
|
||||
public decimal ShopSuppliesPercent { get; set; }
|
||||
public bool IsRushJob { get; set; }
|
||||
public string DiscountType { get; set; } = "None";
|
||||
public decimal DiscountValue { get; set; }
|
||||
public string? DiscountReason { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; }
|
||||
@@ -509,6 +515,9 @@ public class JobEditItemsViewModel
|
||||
public string JobNumber { get; set; } = string.Empty;
|
||||
public int? CustomerId { get; set; }
|
||||
public decimal TaxPercent { get; set; }
|
||||
public int? OvenCostId { get; set; }
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Kiosk;
|
||||
|
||||
// ── Staff-facing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
|
||||
public class SendRemoteLinkDto
|
||||
{
|
||||
[Required, EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional — used to personalise the email greeting.</summary>
|
||||
public string? CustomerName { get; set; }
|
||||
}
|
||||
|
||||
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
|
||||
public class SubmitKioskContactDto
|
||||
{
|
||||
[Required, MaxLength(100)]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
|
||||
[Required, Phone]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
[Required, EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
public bool IsReturningCustomer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Step 2 — Job description submitted by the customer.</summary>
|
||||
public class SubmitKioskJobDto
|
||||
{
|
||||
[Required, MaxLength(2000)]
|
||||
public string JobDescription { get; set; } = string.Empty;
|
||||
|
||||
public string? HowDidYouHearAboutUs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
|
||||
public class SubmitKioskTermsDto
|
||||
{
|
||||
[Required]
|
||||
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
|
||||
public bool AgreedToTerms { get; set; }
|
||||
|
||||
public bool SmsOptIn { get; set; }
|
||||
|
||||
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
|
||||
public string? SignatureDataBase64 { get; set; }
|
||||
}
|
||||
|
||||
// ── Staff review list ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
|
||||
public class KioskSessionListDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public Guid SessionToken { get; set; }
|
||||
public KioskSessionType SessionType { get; set; }
|
||||
public KioskSessionStatus Status { get; set; }
|
||||
public string CustomerFirstName { get; set; } = string.Empty;
|
||||
public string CustomerLastName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
public string JobDescription { get; set; } = string.Empty;
|
||||
public bool SmsOptIn { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
public int? LinkedJobId { get; set; }
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
|
||||
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||
public string JobDescriptionSnippet =>
|
||||
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||
}
|
||||
@@ -59,6 +59,8 @@ public class QuoteDto
|
||||
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; }
|
||||
@@ -127,6 +129,7 @@ public class QuoteDto
|
||||
|
||||
// Conversion Tracking
|
||||
public int? ConvertedToJobId { get; set; }
|
||||
public string? ConvertedToJobNumber { get; set; }
|
||||
|
||||
// Customer Approval Tracking
|
||||
public string? ApprovalToken { get; set; }
|
||||
@@ -185,6 +188,9 @@ public class CreateQuoteDto
|
||||
[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; }
|
||||
@@ -321,6 +327,9 @@ public class UpdateQuoteDto
|
||||
[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; }
|
||||
@@ -684,6 +693,16 @@ public class ConvertQuoteToCustomerDto
|
||||
[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; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -744,6 +763,16 @@ public class CreateQuoteItemCoatDto
|
||||
/// 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>
|
||||
|
||||
@@ -41,6 +41,8 @@ public class CompanyUserDto
|
||||
public bool CanManageMaintenance { get; set; }
|
||||
public bool CanManageInvoices { get; set; }
|
||||
public bool CanViewReports { get; set; }
|
||||
public bool CanManageBills { get; set; }
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
|
||||
[Display(Name = "Send Welcome Email")]
|
||||
public bool SendWelcomeEmail { get; set; } = true;
|
||||
}
|
||||
@@ -258,4 +266,10 @@ public class UpdateCompanyUserDto
|
||||
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ public class CreateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; } = false;
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
@@ -201,6 +204,9 @@ public class UpdateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; }
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; }
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
|
||||
@@ -43,4 +43,33 @@ public interface IAccountingAiService
|
||||
/// Returns a ranked list of flagged items with recommended actions.
|
||||
/// </summary>
|
||||
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
|
||||
/// a statement. Returns a ranked list of suggestions with confidence scores based on
|
||||
/// amount/date patterns and the gap between the current cleared balance and the
|
||||
/// statement ending balance.
|
||||
/// </summary>
|
||||
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Predicts likelihood of late payment for each open AR customer using their historical
|
||||
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
|
||||
/// Returns risk levels (high/medium/low) and estimated days to collection.
|
||||
/// </summary>
|
||||
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
|
||||
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
|
||||
/// and an optional follow-up question suggestion.
|
||||
/// </summary>
|
||||
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes 6–12 months of bill history to detect recurring payment patterns per vendor.
|
||||
/// Returns detected patterns with frequency, typical amount, next expected date, and
|
||||
/// suggested actions (e.g. set a reminder, create a template).
|
||||
/// </summary>
|
||||
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
|
||||
/// Read-only service for financial aggregate reports. All methods query the database
|
||||
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
|
||||
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
|
||||
/// The <paramref name="method"/> parameter overrides the company's stored preference when
|
||||
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
|
||||
/// </summary>
|
||||
public interface IFinancialReportService
|
||||
{
|
||||
/// <summary>Returns a Profit & Loss report for the given company and date range.</summary>
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
|
||||
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
|
||||
@@ -23,4 +26,27 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
|
||||
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
|
||||
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
||||
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for
|
||||
/// operating activities. Investing and Financing sections are derived from account-level data.
|
||||
/// BeginningCash is computed from all cash/bank account credits and debits prior to
|
||||
/// <paramref name="from"/>; EndingCash adds the net change during the period.
|
||||
/// </summary>
|
||||
Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
@@ -47,8 +47,10 @@ public interface IInventoryAiLookupService
|
||||
/// <summary>
|
||||
/// Fetch cure specs, color families, finish, and clear-coat data from a known product URL.
|
||||
/// Skips the Serper search step; used after a catalog hit to augment catalog fields.
|
||||
/// When <paramref name="tdsFallbackUrl"/> is supplied and cure specs are still null after
|
||||
/// the main fetch, the TDS page is tried automatically before returning.
|
||||
/// </summary>
|
||||
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName);
|
||||
Task<InventoryAiLookupResult> LookupByUrlAsync(string url, string? colorName, string? tdsFallbackUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Read a powder label photo and extract manufacturer, color name, SKU, and cure specs
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IJobItemAssemblyService
|
||||
{
|
||||
JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ public interface INotificationService
|
||||
/// Notify when a quote is created/sent. Handles both registered customers and prospects.
|
||||
/// Optionally attaches the quote PDF to the email.
|
||||
/// </summary>
|
||||
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null);
|
||||
Task NotifyQuoteSentAsync(Quote quote, byte[]? pdfAttachment = null, string? pdfFilename = null, string? overrideEmail = null);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the quote approval link to the customer via SMS.
|
||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
||||
/// Notify customer when an invoice has been sent.
|
||||
/// Optionally includes an online payment link in the email body.
|
||||
/// </summary>
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null);
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||
|
||||
@@ -42,10 +42,19 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
|
||||
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IQuotePricingAssemblyService
|
||||
{
|
||||
void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult);
|
||||
|
||||
Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc);
|
||||
}
|
||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,13 @@ public class InvoiceProfile : Profile
|
||||
? s.Customer.CompanyName
|
||||
: $"{s.Customer.ContactFirstName} {s.Customer.ContactLastName}".Trim())
|
||||
: string.Empty))
|
||||
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null ? s.Customer.Email : null))
|
||||
.ForMember(d => d.CustomerEmail, o => o.MapFrom(s => s.Customer != null
|
||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||
: null))
|
||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||
: null))
|
||||
|
||||
@@ -52,6 +52,7 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.PrepServiceIds, opt => opt.MapFrom(src =>
|
||||
src.JobPrepServices.Select(jps => jps.PrepServiceId).ToList()))
|
||||
.ForMember(dest => dest.TimeEntries, opt => opt.MapFrom(src => src.TimeEntries))
|
||||
.ForMember(dest => dest.DiscountType, opt => opt.MapFrom(src => src.DiscountType.ToString()))
|
||||
.ForMember(dest => dest.IsReworkJob, opt => opt.MapFrom(src => src.IsReworkJob))
|
||||
.ForMember(dest => dest.OriginalJobId, opt => opt.MapFrom(src => src.OriginalJobId))
|
||||
.ForMember(dest => dest.OriginalJobNumber,
|
||||
|
||||
@@ -35,7 +35,7 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.CustomerName, opt => opt.MapFrom(src =>
|
||||
src.Customer != null ? src.Customer.CompanyName : null))
|
||||
.ForMember(dest => dest.CustomerEmail, opt => opt.MapFrom(src =>
|
||||
src.Customer != null ? src.Customer.Email : null))
|
||||
src.Customer != null ? src.Customer.Email : src.ProspectEmail))
|
||||
.ForMember(dest => dest.CustomerMobilePhone, opt => opt.MapFrom(src =>
|
||||
src.Customer != null ? src.Customer.MobilePhone : null))
|
||||
.ForMember(dest => dest.CustomerNotifyBySms, opt => opt.MapFrom(src =>
|
||||
@@ -78,6 +78,7 @@ public class QuoteProfile : Profile
|
||||
// CreateQuoteDto -> Quote
|
||||
CreateMap<CreateQuoteDto, Quote>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Set by controller on consent
|
||||
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Generated by controller
|
||||
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK to Draft status
|
||||
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
|
||||
@@ -111,6 +112,7 @@ public class QuoteProfile : Profile
|
||||
// UpdateQuoteDto -> Quote
|
||||
CreateMap<UpdateQuoteDto, Quote>()
|
||||
.ForMember(dest => dest.QuoteNumber, opt => opt.Ignore()) // Cannot change
|
||||
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.Ignore()) // Managed by controller
|
||||
.ForMember(dest => dest.CustomerId, opt => opt.Ignore()) // Cannot change after creation - preserved in controller
|
||||
.ForMember(dest => dest.QuoteStatus, opt => opt.Ignore()) // Will be set by FK
|
||||
.ForMember(dest => dest.OvenCost, opt => opt.Ignore())
|
||||
@@ -277,6 +279,8 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.ZipCode, opt => opt.MapFrom(src => src.ProspectZipCode))
|
||||
.ForMember(dest => dest.IsCommercial, opt => opt.MapFrom(src => src.IsCommercial))
|
||||
.ForMember(dest => dest.CreditLimit, opt => opt.MapFrom(src => 0m))
|
||||
.ForMember(dest => dest.SmsConsent, opt => opt.MapFrom(src => src.ProspectSmsConsent))
|
||||
.ForMember(dest => dest.ProspectSmsConsentedAt, opt => opt.MapFrom(src => src.ProspectSmsConsentedAt))
|
||||
.ForMember(dest => dest.PricingTierId, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.TaxId, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.PaymentTerms, opt => opt.Ignore())
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared file validation and content-type resolution used across all blob storage services.
|
||||
/// </summary>
|
||||
public static class BlobFileHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates an uploaded file against an extension allowlist and a maximum size.
|
||||
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
|
||||
/// </summary>
|
||||
public static (bool IsValid, string Extension, string Error) ValidateUpload(
|
||||
IFormFile? file,
|
||||
string[] allowedExtensions,
|
||||
long maxBytes)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > maxBytes)
|
||||
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
|
||||
|
||||
return (true, extension, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a file extension to its MIME content type, covering common image formats and
|
||||
/// document types. Falls back to <c>application/octet-stream</c>.
|
||||
/// </summary>
|
||||
public static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
|
||||
/// them with underscores to produce a safe blob path segment.
|
||||
/// </summary>
|
||||
public static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,9 @@ public class CatalogImageService : ICatalogImageService
|
||||
string? existingImagePath,
|
||||
string? existingThumbnailPath)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var container = _settings.Containers.CatalogImages;
|
||||
var blobId = Guid.NewGuid().ToString("N");
|
||||
|
||||
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old logo (any extension) before saving new one
|
||||
await DeleteOldLogosAsync(companyId, extension);
|
||||
|
||||
var blobName = GetCompanyLogoPath(companyId, extension);
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
|
||||
@@ -158,20 +152,4 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// The correct content type is required so that browsers render the image
|
||||
/// inline rather than triggering a download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,25 +56,16 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Sanitize filename — replace OS-invalid characters with underscores to
|
||||
// prevent path traversal and blob naming errors in Azure.
|
||||
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
|
||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
fileName = "manual";
|
||||
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
|
||||
|
||||
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
|
||||
@@ -130,19 +121,4 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Correct MIME types are required so browsers open PDFs inline and
|
||||
/// Word documents prompt a compatible application rather than a raw download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
{
|
||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(pricing);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var firstCoat = source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.FirstOrDefault();
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = source.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.TotalPrice * 0.4m,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c =>
|
||||
{
|
||||
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
|
||||
return BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = appearance.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = appearance.ColorCode,
|
||||
Finish = appearance.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = source.ColorName,
|
||||
ColorCode = source.ColorCode,
|
||||
Finish = source.Finish,
|
||||
SurfaceArea = source.SurfaceArea,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItem
|
||||
{
|
||||
JobId = jobId,
|
||||
Description = seed.Description,
|
||||
Quantity = seed.Quantity,
|
||||
ColorName = seed.ColorName,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
SurfaceArea = seed.SurfaceArea,
|
||||
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
|
||||
CatalogItemId = seed.CatalogItemId,
|
||||
IsGenericItem = seed.IsGenericItem,
|
||||
IsLaborItem = seed.IsLaborItem,
|
||||
IsSalesItem = seed.IsSalesItem,
|
||||
IsAiItem = seed.IsAiItem,
|
||||
Sku = seed.Sku,
|
||||
ManualUnitPrice = seed.ManualUnitPrice,
|
||||
PowderCostOverride = seed.PowderCostOverride,
|
||||
UnitPrice = seed.UnitPrice,
|
||||
TotalPrice = seed.TotalPrice,
|
||||
LaborCost = seed.LaborCost,
|
||||
RequiresSandblasting = seed.RequiresSandblasting,
|
||||
RequiresMasking = seed.RequiresMasking,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
Notes = seed.Notes,
|
||||
IncludePrepCost = seed.IncludePrepCost,
|
||||
Complexity = seed.Complexity,
|
||||
AiTags = seed.AiTags,
|
||||
AiPredictionId = seed.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
CoatName = seed.CoatName,
|
||||
Sequence = seed.Sequence,
|
||||
InventoryItemId = seed.InventoryItemId,
|
||||
ColorName = seed.ColorName,
|
||||
VendorId = seed.VendorId,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
|
||||
TransferEfficiency = seed.TransferEfficiency,
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return seeds?
|
||||
.Select(seed => new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
PrepServiceId = seed.PrepServiceId,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
BlastSetupId = seed.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||
{
|
||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||
return storedPowderToOrder;
|
||||
|
||||
if (surfaceAreaSqFt <= 0)
|
||||
return null;
|
||||
|
||||
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
|
||||
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
|
||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||
}
|
||||
|
||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||
string? colorName,
|
||||
string? colorCode,
|
||||
string? finish,
|
||||
InventoryItem? inventoryItem)
|
||||
{
|
||||
if (inventoryItem == null)
|
||||
return (colorName, colorCode, finish);
|
||||
|
||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||
}
|
||||
|
||||
private sealed class JobItemSeed
|
||||
{
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public decimal Quantity { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal? SurfaceArea { get; init; }
|
||||
public decimal SurfaceAreaSqFt { get; init; }
|
||||
public int? CatalogItemId { get; init; }
|
||||
public bool IsGenericItem { get; init; }
|
||||
public bool IsLaborItem { get; init; }
|
||||
public bool IsSalesItem { get; init; }
|
||||
public bool IsAiItem { get; init; }
|
||||
public string? Sku { get; init; }
|
||||
public decimal? ManualUnitPrice { get; init; }
|
||||
public decimal? PowderCostOverride { get; init; }
|
||||
public decimal UnitPrice { get; init; }
|
||||
public decimal TotalPrice { get; init; }
|
||||
public decimal LaborCost { get; init; }
|
||||
public bool RequiresSandblasting { get; init; }
|
||||
public bool RequiresMasking { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool IncludePrepCost { get; init; }
|
||||
public string? Complexity { get; init; }
|
||||
public string? AiTags { get; init; }
|
||||
public int? AiPredictionId { get; init; }
|
||||
}
|
||||
|
||||
private sealed class JobItemCoatSeed
|
||||
{
|
||||
public string CoatName { get; init; } = string.Empty;
|
||||
public int Sequence { get; init; }
|
||||
public int? InventoryItemId { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public int? VendorId { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal CoverageSqFtPerLb { get; init; }
|
||||
public decimal TransferEfficiency { get; init; }
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
|
||||
private sealed class JobItemPrepServiceSeed
|
||||
{
|
||||
public int PrepServiceId { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public int? BlastSetupId { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
|
||||
string? caption = null,
|
||||
JobPhotoType photoType = JobPhotoType.Progress)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// SECURITY: Use GUID for blob name to prevent enumeration
|
||||
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
|
||||
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -98,7 +98,12 @@ public class PdfService : IPdfService
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
page.Content().Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
if (invoiceDto.Status == InvoiceStatus.Paid)
|
||||
layers.Layer().Element(c => ComposePaidStamp(c));
|
||||
});
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber();
|
||||
@@ -153,7 +158,6 @@ public class PdfService : IPdfService
|
||||
if (invoice.DueDate.HasValue)
|
||||
column.Item().Text($"Due: {invoice.DueDate.Value:MMMM d, yyyy}").FontSize(9).FontColor(
|
||||
invoice.Status == Core.Enums.InvoiceStatus.Overdue ? Colors.Red.Medium : Colors.Grey.Darken2);
|
||||
column.Item().Text($"Status: {invoice.Status}").FontSize(9);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -161,6 +165,27 @@ public class PdfService : IPdfService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
|
||||
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
|
||||
/// external Skia/SkiaSharp dependency is needed.
|
||||
/// </summary>
|
||||
private static void ComposePaidStamp(IContainer container)
|
||||
{
|
||||
container
|
||||
.AlignCenter()
|
||||
.AlignMiddle()
|
||||
.Rotate(-45f)
|
||||
.Border(5)
|
||||
.BorderColor(Colors.Green.Darken2)
|
||||
.PaddingVertical(14)
|
||||
.PaddingHorizontal(28)
|
||||
.Text("PAID")
|
||||
.FontSize(80)
|
||||
.Bold()
|
||||
.FontColor(Colors.Green.Darken2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
||||
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
||||
@@ -640,12 +665,13 @@ public class PdfService : IPdfService
|
||||
{
|
||||
column.Item().PaddingTop(10).Table(table =>
|
||||
{
|
||||
// Define columns: Description, Color, Qty, Total
|
||||
// Define columns: Description, Color, Qty, Unit Price, Total
|
||||
table.ColumnsDefinition(columns =>
|
||||
{
|
||||
columns.RelativeColumn(4); // Description
|
||||
columns.RelativeColumn(2); // Color
|
||||
columns.ConstantColumn(40); // Qty
|
||||
columns.ConstantColumn(80); // Unit Price
|
||||
columns.ConstantColumn(80); // Total
|
||||
});
|
||||
|
||||
@@ -655,6 +681,7 @@ public class PdfService : IPdfService
|
||||
header.Cell().Background(accentColor).Padding(5).Text("Description").FontSize(9).Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(accentColor).Padding(5).Text("Color").FontSize(9).Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(accentColor).Padding(5).Text("Qty").FontSize(9).Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Unit Price").FontSize(9).Bold().FontColor(Colors.White);
|
||||
header.Cell().Background(accentColor).Padding(5).AlignRight().Text("Total").FontSize(9).Bold().FontColor(Colors.White);
|
||||
});
|
||||
|
||||
@@ -757,6 +784,9 @@ public class PdfService : IPdfService
|
||||
// Quantity (centered)
|
||||
table.Cell().Background(bgColor).Padding(5).AlignCenter().Text(item.Quantity.ToString()).FontSize(9);
|
||||
|
||||
// Unit Price (right-aligned)
|
||||
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.UnitPrice:N2}").FontSize(9);
|
||||
|
||||
// Total (right-aligned, bold)
|
||||
table.Cell().Background(bgColor).Padding(5).AlignRight().Text($"${item.TotalPrice:N2}").FontSize(9).Bold();
|
||||
|
||||
@@ -1828,6 +1858,50 @@ public class PdfService : IPdfService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#7c3aed";
|
||||
const string gold = "#b45309";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var doc = Document.Create(container =>
|
||||
{
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.75f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return doc.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||
@@ -2327,4 +2401,356 @@ public class PdfService : IPdfService
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
|
||||
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
|
||||
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#b91c1c";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
|
||||
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||||
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
|
||||
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
|
||||
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
|
||||
});
|
||||
|
||||
if (!dto.Vendors.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("All bills are paid — no outstanding balances.")
|
||||
.FontSize(11).FontColor("#16a34a");
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(3);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Vendor", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
|
||||
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
|
||||
{
|
||||
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
|
||||
|
||||
vendCol.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
|
||||
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
|
||||
{
|
||||
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
|
||||
: bill.DaysOverdue <= 30 ? "#ca8a04"
|
||||
: bill.DaysOverdue <= 60 ? "#ea580c"
|
||||
: bill.DaysOverdue <= 90 ? "#dc2626"
|
||||
: "#7f1d1d";
|
||||
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
|
||||
|
||||
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
|
||||
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
|
||||
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
|
||||
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
|
||||
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
|
||||
table.Cell().Background("#f1f5f9");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
|
||||
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
|
||||
/// totals and a balanced/unbalanced indicator.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#1a56db";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
|
||||
dto.IsBalanced ? "#16a34a" : "#dc2626");
|
||||
});
|
||||
|
||||
if (!dto.Lines.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("No active accounts with balances found.")
|
||||
.FontSize(11).FontColor(Colors.Grey.Darken1);
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.ConstantColumn(70);
|
||||
cols.RelativeColumn(4);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var line in dto.Lines)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
|
||||
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
|
||||
/// visually distinguish it from the other financial statements.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#0891b2";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
|
||||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Spacing(4);
|
||||
|
||||
// ── Operating Activities ──────────────────────────────────────
|
||||
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
|
||||
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
|
||||
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
|
||||
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.InvestingLines.Count == 0)
|
||||
CfRow(t, "No investing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.InvestingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.FinancingLines.Count == 0)
|
||||
CfRow(t, "No financing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.FinancingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────
|
||||
col.Item().PaddingTop(12).Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
|
||||
void SumRow(string label, decimal amount, bool bold = false)
|
||||
{
|
||||
var bg = bold ? "#e0f2fe" : "#ffffff";
|
||||
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
|
||||
if (bold) lText.Bold();
|
||||
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
if (bold) vText.Bold();
|
||||
}
|
||||
|
||||
SumRow("Beginning Cash Balance", dto.BeginningCash);
|
||||
SumRow("Net Change in Cash", dto.NetChangeInCash);
|
||||
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
|
||||
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
|
||||
{
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6)
|
||||
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
|
||||
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
|
||||
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
|
||||
{
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
|
||||
.Text(label).Bold().FontSize(9);
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).Bold().FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,9 +126,11 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
|
||||
// A coat is "custom" (must be purchased) when it has no inventory item but has a manual price.
|
||||
// In-stock coats reference an inventory item that already has stock on hand.
|
||||
// Incoming coats reference an inventory item with IsIncoming=true (ordered, not yet received).
|
||||
bool isCustomPowder = !coat.InventoryItemId.HasValue
|
||||
&& coat.PowderCostPerLb.HasValue
|
||||
&& coat.PowderCostPerLb.Value > 0;
|
||||
bool isIncomingPowder = false;
|
||||
|
||||
if (coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
@@ -143,13 +145,14 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
}
|
||||
else if (coat.InventoryItemId.HasValue && coat.InventoryItemId.Value > 0)
|
||||
{
|
||||
// In-stock powder - use inventory cost
|
||||
// In-stock or incoming powder - use inventory cost
|
||||
try
|
||||
{
|
||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||
if (inventoryItem != null && inventoryItem.UnitCost > 0)
|
||||
{
|
||||
costPerLb = inventoryItem.UnitCost;
|
||||
isIncomingPowder = inventoryItem.IsIncoming;
|
||||
var coverage = coat.CoverageSqFtPerLb;
|
||||
var transferEfficiency = coat.TransferEfficiency;
|
||||
|
||||
@@ -157,8 +160,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
|
||||
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
|
||||
|
||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem}, UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||
coat.CoatName, inventoryItem.Name, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
|
||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||
coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -172,13 +175,13 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
var batchSurfaceAreaSqFt = perItemSurfaceAreaSqFt * quantity;
|
||||
decimal coatMaterialCost;
|
||||
|
||||
if (batchSurfaceAreaSqFt > 0 && isCustomPowder && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
// Custom or incoming powder must be purchased for this job — charge for the full ordered
|
||||
// quantity so the shop recovers the actual outlay, not just the calculated usage.
|
||||
if (batchSurfaceAreaSqFt > 0 && (isCustomPowder || isIncomingPowder) && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
// Custom powder that must be purchased: charge for the full ordered quantity, not just
|
||||
// the calculated usage. The shop is spending money on the entire order for this job.
|
||||
coatMaterialCost = coat.PowderToOrder.Value * costPerLb;
|
||||
_logger.LogInformation("Coat {CoatName}: Custom powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
|
||||
coat.CoatName, coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
|
||||
_logger.LogInformation("Coat {CoatName}: {PowderKind} powder to order — charging full order qty {Lbs}lb × ${CostPerLb}/lb = ${Total} (calculated usage would have been ${Calc})",
|
||||
coat.CoatName, isIncomingPowder ? "Incoming" : "Custom", coat.PowderToOrder.Value, costPerLb, coatMaterialCost, batchSurfaceAreaSqFt * powderCostPerSqFt);
|
||||
}
|
||||
else if (batchSurfaceAreaSqFt > 0)
|
||||
{
|
||||
@@ -587,53 +590,9 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
QuoteItemPricingResult itemResult;
|
||||
|
||||
// Catalog items - if they have coats, add coat costs to catalog base price
|
||||
if (item.CatalogItemId.HasValue)
|
||||
{
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
|
||||
// (which already includes the catalog base price + coat costs)
|
||||
if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
|
||||
// All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
|
||||
// handles PowderCostOverride, prep cost inclusion, and all item type variants.
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use simple catalog default price
|
||||
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = catalogItemTotal,
|
||||
UnitPrice = catalogItem.DefaultPrice,
|
||||
TotalPrice = catalogItemTotal
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Catalog item not found, create zero result
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = 0,
|
||||
UnitPrice = 0,
|
||||
TotalPrice = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the full pricing calculation
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
|
||||
itemResults.Add(itemResult);
|
||||
}
|
||||
|
||||
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
string userId,
|
||||
int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old photos for this user with different extensions
|
||||
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
|
||||
|
||||
// Blob path mirrors former filesystem path
|
||||
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
|
||||
@@ -172,19 +166,4 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,19 +50,13 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
|
||||
IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var tempId = Guid.NewGuid().ToString("N");
|
||||
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
||||
var contentType = GetContentType(ext);
|
||||
var contentType = BlobFileHelper.GetContentType(ext);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
|
||||
@@ -100,7 +94,7 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
return (false, string.Empty, "Failed to read temp photo.");
|
||||
|
||||
using var ms = new MemoryStream(download.Content);
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext));
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
|
||||
if (!upload.Success)
|
||||
return (false, string.Empty, "Failed to save permanent photo.");
|
||||
|
||||
@@ -173,12 +167,4 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetContentType(string ext) => ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IPricingCalculationService _pricingService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ILogger<QuotePricingAssemblyService> _logger;
|
||||
|
||||
public QuotePricingAssemblyService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPricingCalculationService pricingService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ILogger<QuotePricingAssemblyService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_pricingService = pricingService;
|
||||
_aiLookupService = aiLookupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||
|
||||
var items = new List<QuoteItem>();
|
||||
foreach (var itemDto in itemDtos)
|
||||
{
|
||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
|
||||
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
|
||||
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||
{
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, itemPricing);
|
||||
return;
|
||||
}
|
||||
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
var unitPrice = itemDto.PowderCostOverride is > 0
|
||||
? itemDto.PowderCostOverride.Value
|
||||
: catalogItem.DefaultPrice;
|
||||
item.UnitPrice = unitPrice;
|
||||
item.TotalPrice = unitPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, pricing);
|
||||
}
|
||||
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||
return [];
|
||||
|
||||
var coats = new List<QuoteItemCoat>();
|
||||
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
||||
|
||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
companyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
coats.Add(coat);
|
||||
}
|
||||
|
||||
return coats;
|
||||
}
|
||||
|
||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||
return [];
|
||||
|
||||
return itemDto.PrepServices
|
||||
.Select(ps => new QuoteItemPrepService
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes,
|
||||
BlastSetupId = ps.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItem
|
||||
{
|
||||
QuoteId = quoteId,
|
||||
Description = itemDto.Description,
|
||||
Quantity = itemDto.Quantity,
|
||||
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
||||
CatalogItemId = itemDto.CatalogItemId,
|
||||
IsGenericItem = itemDto.IsGenericItem,
|
||||
ManualUnitPrice = itemDto.ManualUnitPrice,
|
||||
PowderCostOverride = itemDto.PowderCostOverride,
|
||||
IsLaborItem = itemDto.IsLaborItem,
|
||||
IsSalesItem = itemDto.IsSalesItem,
|
||||
Sku = itemDto.Sku,
|
||||
RequiresSandblasting = itemDto.RequiresSandblasting,
|
||||
RequiresMasking = itemDto.RequiresMasking,
|
||||
EstimatedMinutes = itemDto.EstimatedMinutes,
|
||||
IncludePrepCost = itemDto.IncludePrepCost,
|
||||
Notes = itemDto.Notes,
|
||||
Complexity = itemDto.Complexity,
|
||||
IsAiItem = itemDto.IsAiItem,
|
||||
AiTags = itemDto.AiTags,
|
||||
AiPredictionId = itemDto.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItemCoat
|
||||
{
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
Finish = coatDto.Finish,
|
||||
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = coatDto.PowderToOrder,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||
{
|
||||
item.UnitPrice = pricing.UnitPrice;
|
||||
item.TotalPrice = pricing.TotalPrice;
|
||||
item.ItemMaterialCost = pricing.MaterialCost;
|
||||
item.ItemLaborCost = pricing.LaborCost;
|
||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||
}
|
||||
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
|
||||
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
|
||||
if (prediction == null) return;
|
||||
|
||||
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
|
||||
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
|
||||
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
if (catalogItem == null) return null;
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
var matchedVendor = vendors.FirstOrDefault(v =>
|
||||
v.CompanyName.ToLower().Contains(vendorNameLower) ||
|
||||
vendorNameLower.Contains(v.CompanyName.ToLower()));
|
||||
|
||||
var code = coatingCategory != null
|
||||
? (coatingCategory.CategoryCode.Length >= 4
|
||||
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
||||
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
|
||||
: "POWD";
|
||||
var prefix = $"{code}-{DateTime.Now:yyMM}-";
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
||||
|
||||
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
|
||||
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
|
||||
|
||||
var description = catalogItem.Description;
|
||||
var finish = catalogItem.Finish;
|
||||
var colorFamilies = catalogItem.ColorFamilies;
|
||||
var cureTemp = catalogItem.CureTemperatureF;
|
||||
var cureTime = catalogItem.CureTimeMinutes;
|
||||
var coverage = catalogItem.CoverageSqFtPerLb;
|
||||
var transferEff = catalogItem.TransferEfficiency;
|
||||
var specificGravity = catalogItem.SpecificGravity;
|
||||
var imageUrl = catalogItem.ImageUrl;
|
||||
var sdsUrl = catalogItem.SdsUrl;
|
||||
var tdsUrl = catalogItem.TdsUrl;
|
||||
|
||||
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
|
||||
(string.IsNullOrWhiteSpace(description) ||
|
||||
string.IsNullOrWhiteSpace(colorFamilies) ||
|
||||
cureTemp == null || cureTime == null);
|
||||
if (needsAugment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
|
||||
if (augmented.Success)
|
||||
{
|
||||
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
|
||||
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
|
||||
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
|
||||
cureTemp ??= augmented.CureTemperatureF;
|
||||
cureTime ??= augmented.CureTimeMinutes;
|
||||
coverage ??= augmented.CoverageSqFtPerLb;
|
||||
transferEff ??= augmented.TransferEfficiency;
|
||||
specificGravity ??= augmented.SpecificGravity;
|
||||
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
|
||||
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
|
||||
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
|
||||
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var item = new InventoryItem
|
||||
{
|
||||
SKU = sku,
|
||||
Name = name,
|
||||
Description = description,
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber = catalogItem.Sku,
|
||||
Finish = finish,
|
||||
ColorFamilies = colorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
CoverageSqFtPerLb = coverage ?? 30m,
|
||||
TransferEfficiency = transferEff ?? 65m,
|
||||
CureTemperatureF = cureTemp,
|
||||
CureTimeMinutes = cureTime,
|
||||
SpecificGravity = specificGravity,
|
||||
SpecPageUrl = catalogItem.ProductUrl,
|
||||
ImageUrl = imageUrl,
|
||||
SdsUrl = sdsUrl,
|
||||
TdsUrl = tdsUrl,
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
PrimaryVendorId = matchedVendor?.Id,
|
||||
InventoryCategoryId = coatingCategory?.Id,
|
||||
Category = coatingCategory?.DisplayName ?? "Powder Coating",
|
||||
IsActive = true,
|
||||
IsIncoming = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
coatDto.PowderCostPerLb = null;
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
||||
item.Id, item.Name, coatDto.CatalogItemId);
|
||||
|
||||
return item.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||
coatDto.CatalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,10 @@ public class BillPayment : BaseEntity
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
@@ -150,9 +154,305 @@ public class Expense : BaseEntity
|
||||
public string? Memo { get; set; }
|
||||
public string? ReceiptFilePath { get; set; }
|
||||
|
||||
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor? Vendor { get; set; }
|
||||
public virtual Account ExpenseAccount { get; set; } = null!;
|
||||
public virtual Account PaymentAccount { get; set; } = null!;
|
||||
public virtual Job? Job { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual double-entry journal entry. Lines must balance (sum of debits == sum of credits)
|
||||
/// before posting. Once posted the entry is immutable — use Reverse to correct it.
|
||||
/// Entry numbering follows the pattern JE-YYMM-#### scoped per company.
|
||||
/// </summary>
|
||||
public class JournalEntry : BaseEntity
|
||||
{
|
||||
public string EntryNumber { get; set; } = string.Empty;
|
||||
public DateTime EntryDate { get; set; } = DateTime.UtcNow;
|
||||
public string? Reference { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public JournalEntryStatus Status { get; set; } = JournalEntryStatus.Draft;
|
||||
|
||||
/// <summary>True if this entry was machine-generated as a reversal of another entry.</summary>
|
||||
public bool IsReversal { get; set; } = false;
|
||||
/// <summary>FK to the original entry being reversed. Null for normal entries.</summary>
|
||||
public int? ReversalOfId { get; set; }
|
||||
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public string? PostedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<JournalEntryLine> Lines { get; set; } = new List<JournalEntryLine>();
|
||||
public virtual JournalEntry? ReversalOf { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One debit or credit line within a <see cref="JournalEntry"/>. Either DebitAmount or CreditAmount
|
||||
/// should be non-zero per line (not both). LineOrder controls display sequence.
|
||||
/// </summary>
|
||||
public class JournalEntryLine : BaseEntity
|
||||
{
|
||||
public int JournalEntryId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
public decimal DebitAmount { get; set; }
|
||||
public decimal CreditAmount { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int LineOrder { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bank reconciliation session for a single bank/cash account against a statement.
|
||||
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
|
||||
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
|
||||
/// </summary>
|
||||
public class BankReconciliation : BaseEntity
|
||||
{
|
||||
/// <summary>Must be a bank/cash subtype account.</summary>
|
||||
public int AccountId { get; set; }
|
||||
public DateTime StatementDate { get; set; }
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal EndingBalance { get; set; }
|
||||
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
|
||||
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
|
||||
/// Numbering: VC-YYMM-####
|
||||
/// </summary>
|
||||
public class VendorCredit : BaseEntity
|
||||
{
|
||||
public string CreditNumber { get; set; } = string.Empty;
|
||||
public int VendorId { get; set; }
|
||||
/// <summary>AP account this credit reduces (default: Accounts Payable 2000).</summary>
|
||||
public int APAccountId { get; set; }
|
||||
public DateTime CreditDate { get; set; } = DateTime.UtcNow;
|
||||
public VendorCreditStatus Status { get; set; } = VendorCreditStatus.Open;
|
||||
public decimal Total { get; set; }
|
||||
public decimal RemainingAmount { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
|
||||
public DateTime? PostedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
public virtual Account APAccount { get; set; } = null!;
|
||||
public virtual ICollection<VendorCreditLineItem> LineItems { get; set; } = new List<VendorCreditLineItem>();
|
||||
public virtual ICollection<VendorCreditApplication> Applications { get; set; } = new List<VendorCreditApplication>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single line on a vendor credit, each reversing a specific expense/COGS account.
|
||||
/// </summary>
|
||||
public class VendorCreditLineItem : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
/// <summary>Expense/COGS account being reversed by this line.</summary>
|
||||
public int? AccountId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Account? Account { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the application of a vendor credit against a specific vendor bill.
|
||||
/// No additional GL posting is needed — AP was already adjusted when the credit was posted.
|
||||
/// </summary>
|
||||
public class VendorCreditApplication : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
public int BillId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A saved recipe for a document that should be automatically created on a recurring schedule.
|
||||
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
|
||||
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
|
||||
/// <para>
|
||||
/// Bills are created as Draft so the user can review before posting.
|
||||
/// Expenses are created immediately (already-paid transactions).
|
||||
/// </para>
|
||||
/// Numbering: REC-YYMM-####
|
||||
/// </summary>
|
||||
public class RecurringTemplate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public RecurringTemplateType TemplateType { get; set; }
|
||||
public RecurringFrequency Frequency { get; set; }
|
||||
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
|
||||
public int IntervalCount { get; set; } = 1;
|
||||
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
|
||||
public DateTime NextFireDate { get; set; }
|
||||
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
|
||||
public DateTime? EndDate { get; set; }
|
||||
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
|
||||
public int? MaxOccurrences { get; set; }
|
||||
/// <summary>How many documents have been generated so far.</summary>
|
||||
public int OccurrenceCount { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
|
||||
public string TemplateData { get; set; } = "{}";
|
||||
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
|
||||
/// invoices when a taxable customer is selected. Companies can define multiple rates for
|
||||
/// different jurisdictions and mark one as default.
|
||||
/// </summary>
|
||||
public class TaxRate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
|
||||
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
|
||||
/// to auto-post monthly depreciation journal entries.
|
||||
/// </summary>
|
||||
public class FixedAsset : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime PurchaseDate { get; set; }
|
||||
public decimal PurchaseCost { get; set; }
|
||||
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
|
||||
public decimal SalvageValue { get; set; } = 0;
|
||||
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
|
||||
public int UsefulLifeMonths { get; set; }
|
||||
/// <summary>Running total of depreciation posted so far.</summary>
|
||||
public decimal AccumulatedDepreciation { get; set; } = 0;
|
||||
public bool IsDisposed { get; set; } = false;
|
||||
public DateTime? DisposalDate { get; set; }
|
||||
|
||||
// Computed — not persisted
|
||||
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
|
||||
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
|
||||
/// <summary>Straight-line monthly depreciation amount.</summary>
|
||||
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
|
||||
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
|
||||
|
||||
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
|
||||
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
|
||||
public int? AssetAccountId { get; set; }
|
||||
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
|
||||
public int? DepreciationExpenseAccountId { get; set; }
|
||||
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
|
||||
public int? AccumDepreciationAccountId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account? AssetAccount { get; set; }
|
||||
public virtual Account? DepreciationExpenseAccount { get; set; }
|
||||
public virtual Account? AccumDepreciationAccount { get; set; }
|
||||
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
|
||||
/// month/year combination; linked to the JournalEntry that was created so the posting
|
||||
/// can be traced back through the GL.
|
||||
/// </summary>
|
||||
public class FixedAssetDepreciationEntry : BaseEntity
|
||||
{
|
||||
public int FixedAssetId { get; set; }
|
||||
public int PeriodYear { get; set; }
|
||||
public int PeriodMonth { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
|
||||
public int? JournalEntryId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual FixedAsset FixedAsset { get; set; } = null!;
|
||||
public virtual JournalEntry? JournalEntry { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named annual budget. Contains one BudgetLine per account per month. Supports
|
||||
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
|
||||
/// one is marked IsDefault for the Budget vs. Actual report.
|
||||
/// </summary>
|
||||
public class Budget : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = false;
|
||||
|
||||
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly budget amount for one account within a Budget. Jan–Dec stored as separate
|
||||
/// columns so the grid editor can write them in a single POST without a line-item loop.
|
||||
/// Annual is a computed property summing all twelve months.
|
||||
/// </summary>
|
||||
public class BudgetLine : BaseEntity
|
||||
{
|
||||
public int BudgetId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public decimal Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
|
||||
public virtual Budget Budget { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed year-end close. The close posts a JE that zeroes all
|
||||
/// Revenue and Expense account balances into Retained Earnings, and marks
|
||||
/// the year as closed so it cannot be closed again.
|
||||
/// </summary>
|
||||
public class YearEndClose : BaseEntity
|
||||
{
|
||||
public int ClosedYear { get; set; }
|
||||
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? ClosedBy { get; set; }
|
||||
public int JournalEntryId { get; set; }
|
||||
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
|
||||
public bool CanManageMaintenance { get; set; } = false;
|
||||
public bool CanManageInvoices { get; set; } = false;
|
||||
public bool CanViewReports { get; set; } = false;
|
||||
public bool CanManageBills { get; set; } = false;
|
||||
public bool CanManageAccounting { get; set; } = false;
|
||||
|
||||
// Profile Photo (filesystem storage)
|
||||
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
||||
|
||||
@@ -105,11 +105,34 @@ public class Company : BaseEntity
|
||||
public bool MarketingEmailOptOut { get; set; } = false;
|
||||
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether financial reports (P&L, Balance Sheet, Cash Flow) use
|
||||
/// cash-basis or accrual-basis presentation. Switchable at any time — no GL
|
||||
/// re-posting occurs. Default is Accrual (standard for most businesses).
|
||||
/// </summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
/// <summary>
|
||||
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
|
||||
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
|
||||
/// </summary>
|
||||
public DateTime? BookLockedThrough { get; set; }
|
||||
|
||||
// Settings
|
||||
public string? TimeZone { get; set; } = "America/New_York";
|
||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
||||
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
||||
|
||||
// Kiosk
|
||||
/// <summary>
|
||||
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
|
||||
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
|
||||
/// tablet can serve the intake form without requiring a logged-in user.
|
||||
/// Null = kiosk not activated. Regenerate to revoke the current device.
|
||||
/// </summary>
|
||||
public string? KioskActivationToken { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||
|
||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||
public string? QbMigrationStateJson { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
/// <summary>
|
||||
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
|
||||
/// Quote aligns with the default Terms text ("subject to a formal quote").
|
||||
/// Job is for shops that price on the spot and want the work order ready immediately.
|
||||
/// </summary>
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
|
||||
// Guided activation / first-workflow onboarding
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
|
||||
@@ -6,6 +6,7 @@ public class Customer : BaseEntity
|
||||
public string? ContactFirstName { get; set; }
|
||||
public string? ContactLastName { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? BillingEmail { get; set; } // Accounting/invoicing email for commercial customers
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Address { get; set; }
|
||||
|
||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
||||
public string? Notes { get; set; }
|
||||
public string? RecordedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
|
||||
/// so the Trial Balance can immediately debit the correct bank account.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// Applied to invoice when invoice is created
|
||||
public int? AppliedToInvoiceId { get; set; }
|
||||
public DateTime? AppliedDate { get; set; }
|
||||
|
||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||
public int? SourceInvoiceItemId { get; set; }
|
||||
|
||||
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||
public Guid? BatchId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Customer? RecipientCustomer { get; set; }
|
||||
public virtual Customer? PurchasingCustomer { get; set; }
|
||||
|
||||
@@ -58,6 +58,13 @@ public class InventoryItem : BaseEntity
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? DiscontinuedDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when this item was added to inventory as an ordered-but-not-yet-received powder.
|
||||
/// Staff can quote and print QR codes while the powder is in transit.
|
||||
/// Cleared automatically when a Purchase receipt is posted or staff manually unchecks it.
|
||||
/// </summary>
|
||||
public bool IsIncoming { get; set; } = false;
|
||||
|
||||
// ── Financial Account Mapping ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
||||
|
||||
/// <summary>
|
||||
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
|
||||
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
|
||||
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
|
||||
/// </summary>
|
||||
public string? PublicViewToken { get; set; }
|
||||
|
||||
// Online payments (Stripe Connect)
|
||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
||||
@@ -42,6 +49,19 @@ public class Invoice : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
/// Parsed from the customer's payment terms when the invoice is created (e.g., "2/10 Net 30").
|
||||
/// Informational only — does not automatically reduce the amount due.
|
||||
/// </summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of days after invoice date within which the early payment discount applies.
|
||||
/// Parsed from the customer's payment terms (e.g., "2/10 Net 30" → 10 days).
|
||||
/// </summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
||||
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
||||
|
||||
@@ -25,9 +25,16 @@ public class Job : BaseEntity
|
||||
// Selected oven (carried over from quote; null = company default rate)
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
// Oven scheduling (carried over from quote)
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
// Pricing
|
||||
public decimal QuotedPrice { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
public decimal OvenBatchCost { get; set; }
|
||||
public decimal ShopSuppliesAmount { get; set; }
|
||||
public decimal ShopSuppliesPercent { get; set; }
|
||||
|
||||
// Discount & rush (mirrors quote fields; preserved through quote→job conversion and job edits)
|
||||
public bool IsRushJob { get; set; }
|
||||
|
||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||
public string? Complexity { get; set; }
|
||||
|
||||
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
|
||||
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
|
||||
public bool IsAiItem { get; set; }
|
||||
|
||||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||||
public string? AiTags { get; set; }
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ public class JobTemplateItem : BaseEntity
|
||||
public int? CatalogItemId { 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; }
|
||||
public bool RequiresSandblasting { get; set; }
|
||||
public bool RequiresMasking { get; set; }
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents one customer self-service intake session — either completed on the front-desk tablet
|
||||
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
|
||||
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
|
||||
/// </summary>
|
||||
public class KioskSession : BaseEntity
|
||||
{
|
||||
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
|
||||
public Guid SessionToken { get; set; } = Guid.NewGuid();
|
||||
|
||||
public KioskSessionType SessionType { get; set; }
|
||||
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
|
||||
|
||||
// ── Step 1 — Contact ─────────────────────────────────────────────────────
|
||||
public string CustomerFirstName { get; set; } = string.Empty;
|
||||
public string CustomerLastName { get; set; } = string.Empty;
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public bool IsReturningCustomer { get; set; }
|
||||
|
||||
// ── Step 2 — Job Description ──────────────────────────────────────────────
|
||||
public string JobDescription { get; set; } = string.Empty;
|
||||
public string? HowDidYouHearAboutUs { get; set; }
|
||||
|
||||
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
|
||||
public bool AgreedToTerms { get; set; }
|
||||
public DateTime? AgreedToTermsAt { get; set; }
|
||||
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
|
||||
public bool SmsOptIn { get; set; }
|
||||
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
|
||||
public string? SignatureDataBase64 { get; set; }
|
||||
|
||||
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||
public int? LinkedJobId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
// ── Remote-only ───────────────────────────────────────────────────────────
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
public DateTime? RemoteLinkSentAt { get; set; }
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
public virtual Customer? LinkedCustomer { get; set; }
|
||||
public virtual Job? LinkedJob { get; set; }
|
||||
}
|
||||
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
|
||||
/// </summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Invoice Invoice { get; set; } = null!;
|
||||
public virtual ApplicationUser? RecordedBy { get; set; }
|
||||
|
||||
@@ -17,6 +17,9 @@ public class Quote : BaseEntity
|
||||
public string? ProspectCity { get; set; }
|
||||
public string? ProspectState { get; set; }
|
||||
public string? ProspectZipCode { get; set; }
|
||||
// TCPA compliance: only true when staff explicitly records verbal SMS consent
|
||||
public bool ProspectSmsConsent { get; set; } = false;
|
||||
public DateTime? ProspectSmsConsentedAt { get; set; }
|
||||
|
||||
// Lookup foreign key (replacing enum)
|
||||
public int QuoteStatusId { get; set; }
|
||||
|
||||
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
|
||||
public DateTime? IssuedDate { get; set; }
|
||||
public string? IssuedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
|
||||
/// the Trial Balance can credit this account when computing bank balance.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// For store-credit refunds: the CreditMemo created on their behalf
|
||||
public int? CreditMemoId { get; set; }
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ public class Vendor : BaseEntity
|
||||
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
// 1099 Contractor tracking
|
||||
/// <summary>When true, this vendor is an independent contractor subject to 1099-NEC reporting.</summary>
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
|
||||
|
||||
@@ -13,6 +13,7 @@ public enum AccountType
|
||||
public enum AccountSubType
|
||||
{
|
||||
// Assets
|
||||
Cash = 8,
|
||||
Checking = 1,
|
||||
Savings = 2,
|
||||
AccountsReceivable = 3,
|
||||
@@ -65,3 +66,61 @@ public enum BillStatus
|
||||
Paid = 3,
|
||||
Voided = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Company-level accounting method preference. Affects how financial reports
|
||||
/// (P&L, Balance Sheet, Cash Flow) query and present data. Switching this
|
||||
/// setting never re-posts historical GL entries — it is a report-time choice only.
|
||||
/// </summary>
|
||||
public enum AccountingMethod
|
||||
{
|
||||
/// <summary>Revenue and expenses recognised when cash changes hands.</summary>
|
||||
Cash = 0,
|
||||
/// <summary>Revenue and expenses recognised when earned/incurred (default).</summary>
|
||||
Accrual = 1
|
||||
}
|
||||
|
||||
public enum BankReconciliationStatus
|
||||
{
|
||||
InProgress = 0,
|
||||
Completed = 1
|
||||
}
|
||||
|
||||
public enum VendorCreditStatus
|
||||
{
|
||||
Open = 0,
|
||||
PartiallyApplied = 1,
|
||||
Applied = 2,
|
||||
Voided = 3
|
||||
}
|
||||
|
||||
/// <summary>Source document type for a recurring template — controls which entity is created on each fire.</summary>
|
||||
public enum RecurringTemplateType
|
||||
{
|
||||
/// <summary>Creates a vendor Bill (Draft, pending user review).</summary>
|
||||
Bill = 1,
|
||||
/// <summary>Creates a direct Expense entry (immediately recorded).</summary>
|
||||
Expense = 2
|
||||
}
|
||||
|
||||
/// <summary>How often a recurring template fires.</summary>
|
||||
public enum RecurringFrequency
|
||||
{
|
||||
Daily = 1,
|
||||
Weekly = 2,
|
||||
BiWeekly = 3,
|
||||
Monthly = 4,
|
||||
Quarterly = 5,
|
||||
Annually = 6
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
|
||||
public enum JournalEntryStatus
|
||||
{
|
||||
/// <summary>Not yet posted — can still be edited or deleted.</summary>
|
||||
Draft = 0,
|
||||
/// <summary>Posted to the GL — immutable; can only be reversed.</summary>
|
||||
Posted = 1,
|
||||
/// <summary>A reversal JE has been created and posted for this entry.</summary>
|
||||
Reversed = 2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PowderCoating.Core.Enums;
|
||||
|
||||
public enum KioskSessionType
|
||||
{
|
||||
InPerson = 0,
|
||||
Remote = 1
|
||||
}
|
||||
|
||||
public enum KioskSessionStatus
|
||||
{
|
||||
Active = 0,
|
||||
Submitted = 1,
|
||||
Expired = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -91,6 +91,35 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<BillPayment> BillPayments { get; }
|
||||
IRepository<Expense> Expenses { get; }
|
||||
|
||||
// Manual Journal Entries
|
||||
IRepository<JournalEntry> JournalEntries { get; }
|
||||
IRepository<JournalEntryLine> JournalEntryLines { get; }
|
||||
|
||||
// Vendor Credits
|
||||
IRepository<VendorCredit> VendorCredits { get; }
|
||||
IRepository<VendorCreditLineItem> VendorCreditLineItems { get; }
|
||||
IRepository<VendorCreditApplication> VendorCreditApplications { get; }
|
||||
|
||||
// Bank Reconciliation
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Tax Rates
|
||||
IRepository<TaxRate> TaxRates { get; }
|
||||
|
||||
// Recurring Transactions
|
||||
IRepository<RecurringTemplate> RecurringTemplates { get; }
|
||||
|
||||
// Fixed Assets
|
||||
IRepository<FixedAsset> FixedAssets { get; }
|
||||
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
|
||||
|
||||
// Budgeting
|
||||
IRepository<Budget> Budgets { get; }
|
||||
IRepository<BudgetLine> BudgetLines { get; }
|
||||
|
||||
// Year-End Close
|
||||
IRepository<YearEndClose> YearEndCloses { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
@@ -125,6 +154,9 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
IRepository<KioskSession> KioskSessions { get; }
|
||||
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||
|
||||
|
||||
@@ -85,4 +85,11 @@ public interface IJobRepository : IRepository<Job>
|
||||
/// Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
|
||||
/// ordered by scheduled date then job number. Used by the Daily Board to surface jobs that
|
||||
/// were never completed and rolled past their scheduled day.
|
||||
/// </summary>
|
||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||
}
|
||||
|
||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
||||
|
||||
/// <summary>
|
||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
||||
/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a
|
||||
/// <c>ChurnRisk</c> badge without a separate round-trip.
|
||||
/// </summary>
|
||||
public record CompanyCountSummary(
|
||||
IReadOnlyDictionary<int, int> JobCounts,
|
||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
|
||||
IReadOnlyDictionary<int, int> Jobs30Counts,
|
||||
IReadOnlyDictionary<int, int> Jobs90Counts,
|
||||
IReadOnlyDictionary<int, DateTime?> LastLoginDates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@@ -26,10 +31,13 @@ public interface ICompanyListService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
||||
/// total unfiltered count for pagination.
|
||||
/// total count for pagination and the count of churned accounts that are currently hidden.
|
||||
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
|
||||
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
|
||||
/// </summary>
|
||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
||||
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||
bool hideChurned = true);
|
||||
|
||||
/// <summary>
|
||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||
|
||||
@@ -324,6 +324,39 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Expense> Expenses { get; set; }
|
||||
|
||||
/// <summary>Manual double-entry journal entries (Draft/Posted/Reversed lifecycle); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<JournalEntry> JournalEntries { get; set; }
|
||||
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
|
||||
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
|
||||
|
||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<TaxRate> TaxRates { get; set; }
|
||||
|
||||
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
|
||||
|
||||
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<FixedAsset> FixedAssets { get; set; }
|
||||
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
|
||||
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
|
||||
|
||||
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Budget> Budgets { get; set; }
|
||||
/// <summary>One row per account per Budget; contains Jan–Dec decimal columns.</summary>
|
||||
public DbSet<BudgetLine> BudgetLines { get; set; }
|
||||
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<YearEndClose> YearEndCloses { get; set; }
|
||||
|
||||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||
public DbSet<VendorCreditLineItem> VendorCreditLineItems { get; set; }
|
||||
/// <summary>Application records linking a vendor credit to a specific bill; soft-delete only.</summary>
|
||||
public DbSet<VendorCreditApplication> VendorCreditApplications { get; set; }
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
|
||||
public DbSet<JobTemplate> JobTemplates { get; set; }
|
||||
@@ -334,6 +367,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Prep-service definitions within a job template item.</summary>
|
||||
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
/// <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; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||
/// No global query filter — SuperAdmin controllers query this directly.
|
||||
@@ -614,6 +651,93 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Journal Entries: tenant-filtered; lines use soft-delete only (child rows)
|
||||
modelBuilder.Entity<JournalEntry>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// Bank Reconciliation: tenant-filtered
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Tax Rates: tenant-filtered
|
||||
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Recurring Templates: tenant-filtered
|
||||
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
|
||||
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
|
||||
// reverse collection for FixedAssets so WithMany() is anonymous for each.
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AssetAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AssetAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.DepreciationExpenseAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AccumDepreciationAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>()
|
||||
.HasOne(e => e.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Budgets: tenant-filtered; BudgetLines soft-delete only
|
||||
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
|
||||
modelBuilder.Entity<BudgetLine>()
|
||||
.HasOne(bl => bl.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(bl => bl.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// YearEndClose: tenant-filtered; links to a specific JE
|
||||
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<YearEndClose>()
|
||||
.HasOne(y => y.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(y => y.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
|
||||
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.Bill)
|
||||
.WithMany()
|
||||
.HasForeignKey(vca => vca.BillId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.VendorCredit)
|
||||
.WithMany(vc => vc.Applications)
|
||||
.HasForeignKey(vca => vca.VendorCreditId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Purchase Orders
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -626,6 +750,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Customer intake kiosk sessions — tenant-filtered + soft delete.
|
||||
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
|
||||
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasIndex(e => e.SessionToken)
|
||||
.IsUnique();
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasOne(k => k.LinkedCustomer)
|
||||
.WithMany()
|
||||
.HasForeignKey(k => k.LinkedCustomerId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasOne(k => k.LinkedJob)
|
||||
.WithMany()
|
||||
.HasForeignKey(k => k.LinkedJobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Account self-referencing hierarchy
|
||||
modelBuilder.Entity<Account>()
|
||||
.HasOne(a => a.ParentAccount)
|
||||
@@ -633,6 +775,34 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(a => a.ParentAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// JournalEntry self-referencing reversal link
|
||||
modelBuilder.Entity<JournalEntry>()
|
||||
.HasOne(je => je.ReversalOf)
|
||||
.WithMany()
|
||||
.HasForeignKey(je => je.ReversalOfId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// BankReconciliation → Account (no cascade)
|
||||
modelBuilder.Entity<BankReconciliation>()
|
||||
.HasOne(br => br.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(br => br.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCredit → APAccount (no cascade)
|
||||
modelBuilder.Entity<VendorCredit>()
|
||||
.HasOne(vc => vc.APAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(vc => vc.APAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCreditLineItem → Account (nullable, no cascade)
|
||||
modelBuilder.Entity<VendorCreditLineItem>()
|
||||
.HasOne(li => li.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(li => li.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor → DefaultExpenseAccount (no cascade)
|
||||
modelBuilder.Entity<Vendor>()
|
||||
.HasOne(s => s.DefaultExpenseAccount)
|
||||
|
||||
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.InvoiceSent,
|
||||
Channel = NotificationChannel.Sms,
|
||||
DisplayName = "Invoice Sent (SMS)",
|
||||
Subject = null,
|
||||
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
|
||||
IsActive = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.PaymentReceived,
|
||||
Channel = NotificationChannel.Email,
|
||||
|
||||
Generated
+9531
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 AddCustomerBillingEmail : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "BillingEmail",
|
||||
table: "Customers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BillingEmail",
|
||||
table: "Customers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(846));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(852));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 6, 19, 59, 56, 264, DateTimeKind.Utc).AddTicks(853));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9537
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddProspectSmsConsent : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "ProspectSmsConsent",
|
||||
table: "Quotes",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ProspectSmsConsentedAt",
|
||||
table: "Quotes",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5347));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5358));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProspectSmsConsent",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ProspectSmsConsentedAt",
|
||||
table: "Quotes");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(648));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(653));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 7, 22, 47, 45, 755, DateTimeKind.Utc).AddTicks(655));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9540
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 AddInventoryIsIncoming : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsIncoming",
|
||||
table: "InventoryItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1857));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1863));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1865));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsIncoming",
|
||||
table: "InventoryItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5347));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 0, 24, 28, 872, DateTimeKind.Utc).AddTicks(5358));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9546
File diff suppressed because it is too large
Load Diff
+83
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddShopSuppliesAmountToJob : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ShopSuppliesAmount",
|
||||
table: "Jobs",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "ShopSuppliesPercent",
|
||||
table: "Jobs",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShopSuppliesAmount",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ShopSuppliesPercent",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1857));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1863));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 1, 37, 3, 534, DateTimeKind.Utc).AddTicks(1865));
|
||||
}
|
||||
}
|
||||
}
|
||||
src/PowderCoating.Infrastructure/Migrations/20260510011252_AddJobTemplateItemSalesFields.Designer.cs
Generated
+9552
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobTemplateItemSalesFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9555
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 AddAccountingMethod : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1); // 1 = Accrual (default for new and existing companies)
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9715
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJournalEntries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
EntryNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
EntryDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Reference = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
IsReversal = table.Column<bool>(type: "bit", nullable: false),
|
||||
ReversalOfId = table.Column<int>(type: "int", nullable: true),
|
||||
PostedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
PostedBy = 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_JournalEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntries_JournalEntries_ReversalOfId",
|
||||
column: x => x.ReversalOfId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntryLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
JournalEntryId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
DebitAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CreditAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LineOrder = 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_JournalEntryLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntries_ReversalOfId",
|
||||
table: "JournalEntries",
|
||||
column: "ReversalOfId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_AccountId",
|
||||
table: "JournalEntryLines",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_JournalEntryId",
|
||||
table: "JournalEntryLines",
|
||||
column: "JournalEntryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntryLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntries");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9951
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorCredits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCredits",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CreditNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
APAccountId = table.Column<int>(type: "int", nullable: false),
|
||||
CreditDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
RemainingAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Memo = 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_VendorCredits", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Accounts_APAccountId",
|
||||
column: x => x.APAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditApplications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
BillId = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
AppliedDate = table.Column<DateTime>(type: "datetime2", 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_VendorCreditApplications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
column: x => x.BillId,
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditLineItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", 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_VendorCreditLineItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_AccountId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_VendorCreditId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_APAccountId",
|
||||
table: "VendorCredits",
|
||||
column: "APAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_VendorId",
|
||||
table: "VendorCredits",
|
||||
column: "VendorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditLineItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCredits");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBankReconciliation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Payments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Payments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Expenses",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BankReconciliations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
StatementDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
BeginningBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
EndingBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompletedBy = table.Column<string>(type: "nvarchar(max)", 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_BankReconciliations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BankReconciliations_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BankReconciliations_AccountId",
|
||||
table: "BankReconciliations",
|
||||
column: "AccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BankReconciliations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10105
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPaymentTermsAndTaxRates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TaxRates",
|
||||
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),
|
||||
Rate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", 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_TaxRates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TaxRates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10186
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRecurringTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RecurringTemplates",
|
||||
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),
|
||||
TemplateType = table.Column<int>(type: "int", nullable: false),
|
||||
Frequency = table.Column<int>(type: "int", nullable: false),
|
||||
IntervalCount = table.Column<int>(type: "int", nullable: false),
|
||||
NextFireDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
MaxOccurrences = table.Column<int>(type: "int", nullable: true),
|
||||
OccurrenceCount = table.Column<int>(type: "int", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
TemplateData = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
LastError = 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_RecurringTemplates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId",
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RecurringTemplates");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId",
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10177
File diff suppressed because it is too large
Load Diff
+91
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DropOrphanVendorCreditId1 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10366
File diff suppressed because it is too large
Load Diff
+199
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFixedAssetsLockAnd1099 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssets",
|
||||
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),
|
||||
PurchaseDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
PurchaseCost = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
SalvageValue = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
UsefulLifeMonths = table.Column<int>(type: "int", nullable: false),
|
||||
AccumulatedDepreciation = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsDisposed = table.Column<bool>(type: "bit", nullable: false),
|
||||
DisposalDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
AssetAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
DepreciationExpenseAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
AccumDepreciationAccountId = table.Column<int>(type: "int", 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_FixedAssets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AccumDepreciationAccountId",
|
||||
column: x => x.AccumDepreciationAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AssetAccountId",
|
||||
column: x => x.AssetAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_DepreciationExpenseAccountId",
|
||||
column: x => x.DepreciationExpenseAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssetDepreciationEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FixedAssetId = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodYear = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodMonth = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
JournalEntryId = table.Column<int>(type: "int", 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_FixedAssetDepreciationEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_FixedAssets_FixedAssetId",
|
||||
column: x => x.FixedAssetId,
|
||||
principalTable: "FixedAssets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_FixedAssetId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "FixedAssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_JournalEntryId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "JournalEntryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AccumDepreciationAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AccumDepreciationAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AssetAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AssetAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_DepreciationExpenseAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "DepreciationExpenseAccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssetDepreciationEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10585
File diff suppressed because it is too large
Load Diff
+185
@@ -0,0 +1,185 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBudgetsAndYearEndClose : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Budgets",
|
||||
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),
|
||||
FiscalYear = table.Column<int>(type: "int", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", 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_Budgets", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "YearEndCloses",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
ClosedYear = table.Column<int>(type: "int", nullable: false),
|
||||
ClosedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ClosedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
JournalEntryId = 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_YearEndCloses", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_YearEndCloses_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BudgetLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
BudgetId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
Jan = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Feb = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Mar = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Apr = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
May = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Jun = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Jul = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Aug = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Sep = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Oct = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Nov = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Dec = table.Column<decimal>(type: "decimal(18,2)", 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_BudgetLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetLines_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_BudgetLines_Budgets_BudgetId",
|
||||
column: x => x.BudgetId,
|
||||
principalTable: "Budgets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetLines_AccountId",
|
||||
table: "BudgetLines",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BudgetLines_BudgetId",
|
||||
table: "BudgetLines",
|
||||
column: "BudgetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_YearEndCloses_JournalEntryId",
|
||||
table: "YearEndCloses",
|
||||
column: "JournalEntryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BudgetLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "YearEndCloses");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Budgets");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10591
File diff suppressed because it is too large
Load Diff
+90
@@ -0,0 +1,90 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountantRolePermissions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CanManageAccounting",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "CanManageBills",
|
||||
table: "AspNetUsers",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
// Grant both new permissions to all existing CompanyAdmin users so they don't lose access
|
||||
migrationBuilder.Sql(@"
|
||||
UPDATE AspNetUsers
|
||||
SET CanManageBills = 1, CanManageAccounting = 1
|
||||
WHERE CompanyRole = 'CompanyAdmin'
|
||||
");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CanManageAccounting",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CanManageBills",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(974));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 55, 8, 322, DateTimeKind.Utc).AddTicks(976));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10594
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 AddJobOvenBatchCost : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "OvenBatchCost",
|
||||
table: "Jobs",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5837));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5846));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 11, 14, 23, 23, 221, DateTimeKind.Utc).AddTicks(5847));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "OvenBatchCost",
|
||||
table: "Jobs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(8999));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9005));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 23, 40, 54, 100, DateTimeKind.Utc).AddTicks(9007));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10594
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user