Compare commits
136 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e313149f08 | |||
| 82fb48f7a5 | |||
| 427c52a499 | |||
| d92266b027 | |||
| 750e1b1c5b | |||
| 94a89ee175 | |||
| 711cd01cd3 | |||
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 | |||
| 03b425a12f | |||
| 8453449833 | |||
| ad986561c9 | |||
| 0d5553f3b2 | |||
| 87bbf158a4 | |||
| f453a95f28 | |||
| d9e98a55d2 | |||
| 99deca3b62 | |||
| 23e64829bb | |||
| cd4c233b60 | |||
| 6c07216c64 | |||
| b23bea6db0 | |||
| cf07356147 | |||
| 39b103a482 | |||
| 4aae2df5b5 | |||
| 3416c242f1 | |||
| 7e31846777 | |||
| ed35362c7a | |||
| 81119035c7 | |||
| 0deef574c3 | |||
| efc4e9dadf | |||
| ca7e905832 | |||
| 32d09b38f1 | |||
| 3cee1307fc | |||
| be89327c01 | |||
| 8f955851e5 | |||
| 972123c7a2 | |||
| 9dd36238bb | |||
| 8ae61b6c78 | |||
| 97745f9a65 | |||
| e124fd5c8b | |||
| 6c2fe6e1c4 | |||
| f625be01a3 | |||
| e6c4cfb38b | |||
| 5b5247624c | |||
| 91a176ce5c | |||
| a7ad0e1de8 | |||
| e4a256a6c4 | |||
| e476b4744d | |||
| 04d16109ae | |||
| f0f3717681 | |||
| e23b006139 | |||
| 0f35946973 | |||
| 19e1ce858f | |||
| 026e646295 | |||
| b7fcefa765 | |||
| 1722cd4124 | |||
| c3742e1585 | |||
| 1a6f855c05 | |||
| d28e639d1b | |||
| 10f668fd73 | |||
| 19b7a9a473 | |||
| 4650ba3d4d | |||
| 1eba50cf0f | |||
| e443457139 | |||
| edf56c1164 | |||
| b9cd693421 | |||
| d77b3778ac | |||
| a7bf97a2df | |||
| 05935b110a | |||
| 64a9c1531b | |||
| f018653c18 | |||
| b7ab85ff92 | |||
| 15b070398b | |||
| 14f220347b | |||
| baec0b33f7 | |||
| ce7b00b68c | |||
| dfb1d34af3 | |||
| c5c1244177 | |||
| 8c86eba4f2 | |||
| d4dddfa727 | |||
| 1bb07162cd | |||
| ec925f9e08 | |||
| 600196f679 | |||
| eb13283e76 | |||
| 30c644a8ec | |||
| 0e480adbf6 | |||
| eaab0af51f | |||
| 51a5268bc2 | |||
| a0bdd2b5b4 | |||
| 21b39161a3 | |||
| b241daf15e | |||
| 25140554ad | |||
| 46cadea367 | |||
| cfe937c0c3 | |||
| 3ad6b0d08f | |||
| fdac0240d1 | |||
| 81dc34bab4 | |||
| b9e9449c8b | |||
| fd38785942 | |||
| 33277de727 | |||
| 4ac62551f4 | |||
| 7fa385aeb8 | |||
| 8452ea3fcd | |||
| 9b34ff564e | |||
| 24f3df1bbc | |||
| 551116d7e5 | |||
| 8768e9813b | |||
| 4a7087cc0c | |||
| 59b152c89f | |||
| 441898b52f | |||
| 3e30397302 | |||
| 31c5746e5b | |||
| 3f9ac27afa | |||
| df504674e9 | |||
| 07796b05c8 | |||
| 2bf8871892 | |||
| 8a0a564885 | |||
| dd4785b048 | |||
| e185e3b7e3 | |||
| 8acbc8605d | |||
| 485f0b69c8 | |||
| f380c152ca | |||
| 79c8c7e6a4 | |||
| 6cf355071b | |||
| ebd474ae81 | |||
| 3c390a2e05 | |||
| 0df2353d4f | |||
| be0a5b26e2 | |||
| 36680eced9 | |||
| 27aa4e0ea6 | |||
| b2d6fae400 | |||
| 3a1928f9bf |
@@ -1,180 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(dotnet build:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(dotnet restore:*)",
|
||||
"Bash(dotnet clean:*)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(dotnet ef migrations add:*)",
|
||||
"Bash(dotnet ef migrations remove:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(dotnet ef database update:*)",
|
||||
"Bash(sqlcmd:*)",
|
||||
"Bash(dotnet ef migrations script:*)",
|
||||
"Bash(dotnet run:*)",
|
||||
"Bash(timeout /t 15 dotnet run:*)",
|
||||
"Bash(timeout /t 10 /nobreak)",
|
||||
"Bash(ping:*)",
|
||||
"Bash(start /B dotnet run:*)",
|
||||
"Bash(test:*)",
|
||||
"Bash(dotnet ef migrations:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(xargs -I {} bash -c 'echo \"\"=== {} ===\"\" && head -20 {} | grep -E \"\"class|Authorize\"\"')",
|
||||
"Bash(powershell:*)",
|
||||
"Bash(dotnet tool install:*)",
|
||||
"Bash(dotnet tool update:*)",
|
||||
"Bash(xargs:*)",
|
||||
"Bash(powershell -Command \"cd src\\\\PowderCoating.Web; dotnet ef migrations add UpdateQuoteForProspects --project ..\\\\PowderCoating.Infrastructure\")",
|
||||
"Bash(powershell -Command:*)",
|
||||
"Bash(taskkill:*)",
|
||||
"Bash(netstat:*)",
|
||||
"Bash(libman restore:*)",
|
||||
"Bash(./start-app.bat)",
|
||||
"Bash(dotnet-ef migrations add:*)",
|
||||
"Bash(dotnet-ef database update:*)",
|
||||
"Bash(./stop-app.bat)",
|
||||
"Bash(timeout /t 3 /nobreak)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(if [ -f \"stop-app.bat\" ])",
|
||||
"Bash(then cmd.exe /c stop-app.bat)",
|
||||
"Bash(else echo \"stop-app.bat not found\")",
|
||||
"Bash(fi)",
|
||||
"Bash(powershell.exe -Command \"Unblock-File -Path 'src/PowderCoating.Web/dotnet-tools.json'\":*)",
|
||||
"Bash(powershell.exe -Command \"Get-Process | Where-Object {$_ProcessName -like ''*PowderCoating*''} | Stop-Process -Force\")",
|
||||
"Bash(powershell.exe:*)",
|
||||
"Bash(Select-String -Pattern \"error|Error\")",
|
||||
"Bash(Select-String -NotMatch \"warning\")",
|
||||
"Bash(tasklist:*)",
|
||||
"Bash(dotnet add package:*)",
|
||||
"Bash(start-process dotnet run:*)",
|
||||
"Bash(Select-Object -ExpandProperty Id)",
|
||||
"Bash(find:*)",
|
||||
"Bash(cmd.exe:*)",
|
||||
"Bash(dotnet ef dbcontext:*)",
|
||||
"Bash(handle \"PowderCoating.Web.pdb\")",
|
||||
"Bash(timeout:*)",
|
||||
"Bash(del /F \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\obj\\\\Debug\\\\net8.0\\\\PowderCoating.Web.pdb\")",
|
||||
"Bash(Select-String -Pattern \"Build succeeded|Build FAILED|error\")",
|
||||
"Bash(Select-Object -Last 10)",
|
||||
"Bash(del \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.cs\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Infrastructure\\\\Migrations\\\\20260211031319_RemovePreexistingCatalogData.Designer.cs\")",
|
||||
"Bash(Select-String:*)",
|
||||
"Bash(Select-Object -Last 5)",
|
||||
"Bash(start-app.bat)",
|
||||
"Bash(dotnet script:*)",
|
||||
"Bash(dotnet list:*)",
|
||||
"Bash(dotnet new:*)",
|
||||
"Bash(stop-app.bat)",
|
||||
"Bash(dotnet watch run:*)",
|
||||
"Bash(cmd /c \"taskkill /F /PID 42108\")",
|
||||
"Bash(cmd /c start-app.bat)",
|
||||
"Bash(\"Y:/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs\":*)",
|
||||
"Bash(/y/PCC/PowderCoatingApp/src/PowderCoating.Application/Services/PdfService.cs:*)",
|
||||
"Bash(/tmp/remove_tempdata.pl:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(perl:*)",
|
||||
"Bash(done)",
|
||||
"Bash(cmd:*)",
|
||||
"Bash(tail:*)",
|
||||
"Bash(del:*)",
|
||||
"Bash(dotnet add:*)",
|
||||
"Bash(python3:*)",
|
||||
"Bash(Stop-Process:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(dotnet tool:*)",
|
||||
"Bash(where libman:*)",
|
||||
"Bash(find \"Y:/PCC/PowderCoatingApp\" -type f \\\\\\( -name \"*template*\" -o -name \"*import*\" -o -name \"*export*\" \\\\\\) -iname \"*.csv\" -o -iname \"*.xlsx\" -o -iname \"*.xls\" 2>/dev/null | head -50)",
|
||||
"Bash(grep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Create.cshtml\" | head -20\ngrep -n \"powderCostOverride\\\\|PowderCostOverride\\\\|pageMeta\\\\|quoteItems\\\\|existingItems\" \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/Quotes/Edit.cshtml\" 2>/dev/null | head -20)",
|
||||
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -20)",
|
||||
"Bash(cd /tmp/sdktest && rm -rf bin obj && cat Program.cs)",
|
||||
"Bash(cat /tmp/sdktest/Program.cs | xxd | head -5)",
|
||||
"WebSearch",
|
||||
"WebFetch(domain:github.com)",
|
||||
"WebFetch(domain:www.nuget.org)",
|
||||
"Bash(wmic process:*)",
|
||||
"Bash(grep -rn \"AI Photo\\\\|ai.*photo\\\\|photo.*quote\\\\|item-type\\\\|AiPhotoQuotes\\\\|ai_photo\" \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\\" | grep -i \"photo\\\\|ai\" | head -20)",
|
||||
"Bash(sed -i 's|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",|\"aiAnalyzeUrl\": \"@Url.Action\\(\\\\\"AiAnalyzeItem\\\\\", \\\\\"Quotes\\\\\"\\)\",\\\\n \"aiPhotoQuotesEnabled\": @Json.Serialize\\(\\(bool\\)\\(ViewBag.AiPhotoQuotesEnabled ?? true\\)\\),|g' \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Quotes\\\\Edit.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Create.cshtml\" \\\\\n \"Y:\\\\PCC\\\\PowderCoatingApp\\\\src\\\\PowderCoating.Web\\\\Views\\\\Jobs\\\\Edit.cshtml\")",
|
||||
"Bash(cp:*)",
|
||||
"Bash(dotnet fsi -e \":*)",
|
||||
"Read(//y/tmp/**)",
|
||||
"Bash(cp /c/Users/spoul/.nuget/packages/stripe.net/50.4.1/stripe.net.50.4.1.nupkg stripe.zip)",
|
||||
"Bash(unzip -o stripe.zip *.cs -d stripe_src)",
|
||||
"Bash(dotnet ef:*)",
|
||||
"Bash(Payment)",
|
||||
"Bash(Deposit \")",
|
||||
"Bash(node:*)",
|
||||
"WebFetch(domain:quickbooks.intuit.com)",
|
||||
"WebFetch(domain:www.saasant.com)",
|
||||
"WebFetch(domain:www.liveflow.com)",
|
||||
"WebFetch(domain:www.gentlefrog.com)",
|
||||
"WebFetch(domain:blog.coupler.io)",
|
||||
"WebFetch(domain:litextension.com)",
|
||||
"WebFetch(domain:www.dancingnumbers.com)",
|
||||
"WebFetch(domain:www.bizbooks.pro)",
|
||||
"WebFetch(domain:support.saasant.com)",
|
||||
"WebFetch(domain:support.getcount.com)",
|
||||
"WebFetch(domain:planergy.com)",
|
||||
"WebFetch(domain:www.wizxpert.com)",
|
||||
"WebFetch(domain:www.trykeep.com)",
|
||||
"WebFetch(domain:gentlefrog.com)",
|
||||
"WebFetch(domain:www.syscloud.com)",
|
||||
"WebFetch(domain:interopay.zendesk.com)",
|
||||
"WebFetch(domain:docs.d-tools.cloud)",
|
||||
"WebFetch(domain:paygration.com)",
|
||||
"Bash([ ! -d \"Y:/PCC/PowderCoatingApp/src/PowderCoating.Web/Views/$controller\" ])",
|
||||
"Bash(bash /tmp/check_actions.sh)",
|
||||
"Bash(bash /tmp/verify_endpoints.sh)",
|
||||
"Bash(bash /tmp/verify_services.sh)",
|
||||
"Read(//y/PCC/Deployments/**)",
|
||||
"Bash(mkdir -p \"Y:/PCC/Deployments\")",
|
||||
"Bash(dotnet-script -e \"using System.Reflection; var a = Assembly.LoadFrom\\(\\\\\"Anthropic.SDK.dll\\\\\"\\); var types = a.GetTypes\\(\\).Where\\(t => t.Name.Contains\\(\\\\\"Document\\\\\"\\) || t.Name.Contains\\(\\\\\"Content\\\\\"\\)\\).Select\\(t => t.Name\\).OrderBy\\(n => n\\); foreach\\(var t in types\\) Console.WriteLine\\(t\\);\")",
|
||||
"Bash(sort -t'-' -k3 -r)",
|
||||
"Bash(wsl grep:*)",
|
||||
"Bash(find src:*)",
|
||||
"Bash(dotnet csharp *)",
|
||||
"Read(//c/Users/spoul/.nuget/packages/stripe.net/50.4.1/lib/netstandard2.0/**)",
|
||||
"Bash(dotnet publish *)",
|
||||
"Bash(Compress-Archive -Path * -DestinationPath \"..\\\\deploy.zip\" -Force)",
|
||||
"Bash(az webapp *)",
|
||||
"Read(//y/PCC/**)",
|
||||
"Bash(Get-Date -Format 'yyyyMMdd_HHmmss')",
|
||||
"PowerShell(Get-Content *)",
|
||||
"PowerShell(dotnet build *)",
|
||||
"PowerShell(New-Item *)",
|
||||
"PowerShell(& \"Y:\\\\PCC\\\\PowderCoatingApp\\\\scripts\\\\generate-migration-script.ps1\")",
|
||||
"PowerShell(if \\(Test-Path \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"\\) { $f = Get-Item \"Y:\\\\pcc\\\\deployment\\\\migrations.sql\"; Write-Host \"File exists: $\\($f.Length\\) bytes\" } else { Write-Host \"File not created\" })",
|
||||
"Bash(git add *)",
|
||||
"Bash(git commit -m ' *)",
|
||||
"Bash(git push *)",
|
||||
"Bash(git commit *)",
|
||||
"Bash(git checkout *)",
|
||||
"Bash(git merge *)",
|
||||
"Bash(dotnet package *)",
|
||||
"Bash(dotnet test *)",
|
||||
"Bash(git rm *)",
|
||||
"Bash(git stash *)",
|
||||
"Bash(dotnet ef *)",
|
||||
"Bash(sqlcmd -S \".\\\\SQLEXPRESS\" -d PowderCoatingDb -Q \"SELECT Id, DisplayName, IsCoating, IsActive FROM InventoryCategoryLookups ORDER BY DisplayOrder\" -W)",
|
||||
"Skill(schedule)",
|
||||
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" log --oneline -10)",
|
||||
"Bash(git -C \"//192.168.0.37/SCPSoftware/tmp/PowderCoatingApp-dev-perf\" status --short)",
|
||||
"Bash(git *)",
|
||||
"Bash(get-childitem -Recurse -Filter \"QuotesController.cs\")",
|
||||
"Bash(Select-Object -ExpandProperty FullName)",
|
||||
"Bash(dotnet user-secrets *)",
|
||||
"Bash(Get-ChildItem -Path \"Y:\\\\PCC\\\\PowderCoatingApp\" -Directory)",
|
||||
"Bash(Select-Object Name)",
|
||||
"Bash(Get-Content *)",
|
||||
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); print\\(f'Total records: {len\\(data\\)}'\\); print\\('First record:'\\); print\\(json.dumps\\(data[0], indent=2\\)\\)\")",
|
||||
"Bash(python -c \"import json; data=json.load\\(open\\('prismatic_powders.json','r',encoding='utf-8'\\)\\); keys=list\\(data.keys\\(\\)\\); print\\('Top-level keys:', keys[:10]\\); first=data[keys[0]]; print\\('First record key:', keys[0]\\); print\\(json.dumps\\(first, indent=2\\)\\)\")",
|
||||
"PowerShell(Get-ChildItem *)",
|
||||
"PowerShell(Select-String *)",
|
||||
"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($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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit hook: block commits containing corrupted Unicode in .cshtml files.
|
||||
#
|
||||
# All corruption variants start with the UTF-8 byte sequence for a-circumflex
|
||||
# followed by euro-sign (bytes C3 A2 E2 82 AC), which is the first two chars
|
||||
# of every known corruption pattern. Grep for that byte sequence in staged files.
|
||||
|
||||
STAGED=$(git diff --cached --name-only | grep '\.cshtml$')
|
||||
if [ -z "$STAGED" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# $'\xc3\xa2\xe2\x82\xac' = UTF-8 bytes for a-circumflex + euro-sign
|
||||
CORRUPT=$(echo "$STAGED" | xargs grep -l $'\xc3\xa2\xe2\x82\xac' 2>/dev/null)
|
||||
|
||||
if [ -n "$CORRUPT" ]; then
|
||||
echo ""
|
||||
echo "ERROR: Corrupted Unicode characters detected in staged .cshtml files:"
|
||||
echo "$CORRUPT" | sed 's/^/ /'
|
||||
echo ""
|
||||
echo "Fix by running: .\\tools\\Fix-Encoding.ps1"
|
||||
echo "Then re-stage the files and commit again."
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,6 +1,11 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
|
||||
# Claude Code tool settings and build logs
|
||||
.claude/settings.local.json
|
||||
.claude/settings.json
|
||||
BuildLog*.txt
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
||||
@@ -0,0 +1,571 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance to Codex (Codex.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
A production-ready ASP.NET Core 8.0 MVC application for managing powder coating business operations. The application implements Clean Architecture with six projects across three layers (Domain, Application, Infrastructure) plus two presentation layers (Web MVC, RESTful API).
|
||||
|
||||
## Essential Commands
|
||||
|
||||
### Building and Running
|
||||
|
||||
```bash
|
||||
# Build entire solution
|
||||
dotnet build
|
||||
|
||||
# Run web application (MVC)
|
||||
cd src/PowderCoating.Web
|
||||
dotnet run
|
||||
# Access at: https://localhost:58461
|
||||
|
||||
# Run web with auto-reload
|
||||
dotnet watch run
|
||||
|
||||
# Run API
|
||||
cd src/PowderCoating.Api
|
||||
dotnet run
|
||||
# Swagger UI at root URL
|
||||
|
||||
# Run tests
|
||||
dotnet test # All tests
|
||||
dotnet test tests/PowderCoating.UnitTests # Unit tests only
|
||||
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
|
||||
```
|
||||
|
||||
### Database Operations
|
||||
|
||||
```bash
|
||||
# All EF commands run from Web project directory
|
||||
cd src/PowderCoating.Web
|
||||
|
||||
# Create migration (must specify Infrastructure project)
|
||||
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Apply migrations
|
||||
dotnet ef database update --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Reset database (WARNING: deletes all data)
|
||||
dotnet ef database drop --project ../PowderCoating.Infrastructure
|
||||
dotnet ef database update --project ../PowderCoating.Infrastructure
|
||||
|
||||
# List migrations
|
||||
dotnet ef migrations list --project ../PowderCoating.Infrastructure
|
||||
|
||||
# Remove last migration (if not applied)
|
||||
dotnet ef migrations remove --project ../PowderCoating.Infrastructure
|
||||
```
|
||||
|
||||
### Default Credentials
|
||||
|
||||
```
|
||||
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
|
||||
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
|
||||
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
|
||||
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
|
||||
```
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Clean Architecture Layers
|
||||
|
||||
**Domain Layer (PowderCoating.Core)**
|
||||
- Contains business entities, enums, and repository interfaces
|
||||
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields)
|
||||
- All entities inherit from BaseEntity and support soft delete
|
||||
- No dependencies on other projects
|
||||
|
||||
**Application Layer (PowderCoating.Application)**
|
||||
- DTOs organized by domain (Customer, Job, Equipment, Inventory, Maintenance)
|
||||
- AutoMapper profiles with reverse mappings
|
||||
- Service interfaces (IFileService, etc.)
|
||||
- No UI or infrastructure dependencies
|
||||
|
||||
**Infrastructure Layer (PowderCoating.Infrastructure)**
|
||||
- `ApplicationDbContext` with global query filters for soft deletes and multi-tenancy
|
||||
- Generic `Repository<T>` implementing `IRepository<T>`
|
||||
- `UnitOfWork` implementing `IUnitOfWork` with lazy-loaded repositories
|
||||
- Seed data is triggered **manually** via Platform Management → Seed Data (not automatic on startup)
|
||||
|
||||
**Presentation Layers**
|
||||
- `PowderCoating.Web`: MVC application with Razor views, Bootstrap 5 UI
|
||||
- `PowderCoating.Api`: RESTful API with JWT authentication, Swagger documentation
|
||||
|
||||
### Key Design Patterns
|
||||
|
||||
**Repository Pattern**
|
||||
- Generic `Repository<T>` in Infrastructure
|
||||
- All CRUD operations, search, pagination, eager loading support
|
||||
- Soft delete with `SoftDeleteAsync()` method
|
||||
|
||||
**Unit of Work Pattern**
|
||||
- Coordinates multiple repositories
|
||||
- Transaction support: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransactionAsync()`
|
||||
- Lazy instantiation of repositories
|
||||
- `SaveChangesAsync()` or `CompleteAsync()` to persist changes
|
||||
|
||||
**Dependency Injection**
|
||||
- All dependencies registered in `Program.cs`
|
||||
- Controllers inject `IUnitOfWork` and `IMapper`
|
||||
- Services are scoped to request lifetime
|
||||
|
||||
**Global Query Filters**
|
||||
- Soft deletes: All queries automatically filter `IsDeleted == false`
|
||||
- Multi-tenancy: Non-SuperAdmin users see only their company data
|
||||
- Bypass with `ignoreQueryFilters: true` parameter in repository methods
|
||||
|
||||
### Multi-Tenancy Implementation
|
||||
|
||||
- `CompanyId` foreign key on all business entities
|
||||
- `ITenantContext` injected into DbContext resolves current company
|
||||
- SuperAdmin role can view all companies
|
||||
- Global query filters enforce company isolation at database level
|
||||
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
|
||||
|
||||
## Data Access Rules (ENFORCE THESE)
|
||||
|
||||
> **`ApplicationDbContext` is NEVER injected into a controller.**
|
||||
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
|
||||
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
|
||||
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
|
||||
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
|
||||
|
||||
### Three tiers — use the right one:
|
||||
|
||||
**Tier 1 — Simple CRUD** → `IUnitOfWork.EntityName` (generic `IRepository<T>`)
|
||||
```csharp
|
||||
var items = await _unitOfWork.CatalogItems.GetAllAsync();
|
||||
await _unitOfWork.Announcements.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
```
|
||||
|
||||
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
|
||||
```csharp
|
||||
// Include chains and domain-specific queries belong in the repository, not the controller
|
||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
|
||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||
```
|
||||
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
|
||||
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
|
||||
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
|
||||
|
||||
**Tier 3 — Aggregate/reporting queries** → injected read services
|
||||
```csharp
|
||||
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
|
||||
var aging = await _financialReports.GetArAgingAsync(companyId);
|
||||
```
|
||||
Services: `IFinancialReportService`, `IOperationalReportService`
|
||||
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
|
||||
|
||||
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
|
||||
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
|
||||
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
|
||||
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
|
||||
|
||||
If you think you need a new exception, you almost certainly don't. Check the spec first.
|
||||
|
||||
---
|
||||
|
||||
## Data Access Patterns
|
||||
|
||||
### Common Controller Pattern
|
||||
|
||||
```csharp
|
||||
public class ExampleController : Controller
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ExampleController(IUnitOfWork unitOfWork, IMapper mapper)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var entities = await _unitOfWork.Examples.GetAllAsync();
|
||||
var dtos = _mapper.Map<List<ExampleDto>>(entities);
|
||||
return View(dtos);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateExampleDto dto)
|
||||
{
|
||||
var entity = _mapper.Map<Example>(dto);
|
||||
await _unitOfWork.Examples.AddAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
await _unitOfWork.Examples.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Unit of Work Repositories
|
||||
|
||||
All entity repositories are available via `IUnitOfWork` properties:
|
||||
- `_unitOfWork.Customers`
|
||||
- `_unitOfWork.Jobs`
|
||||
- `_unitOfWork.JobItems`
|
||||
- `_unitOfWork.Quotes`
|
||||
- `_unitOfWork.InventoryItems`
|
||||
- `_unitOfWork.Equipment`
|
||||
- `_unitOfWork.MaintenanceRecords`
|
||||
- Plus additional entities (Suppliers, JobPhotos, JobNotes, etc.)
|
||||
|
||||
### Eager Loading Related Data
|
||||
|
||||
```csharp
|
||||
// Load customer with related data
|
||||
var customer = await _unitOfWork.Customers.GetByIdAsync(
|
||||
id,
|
||||
c => c.Jobs,
|
||||
c => c.Quotes,
|
||||
c => c.PricingTier
|
||||
);
|
||||
|
||||
// Find with predicate and includes
|
||||
var activeJobs = await _unitOfWork.Jobs.FindAsync(
|
||||
j => j.Status != JobStatus.Completed,
|
||||
j => j.Customer,
|
||||
j => j.JobItems
|
||||
);
|
||||
```
|
||||
|
||||
### Pagination
|
||||
|
||||
```csharp
|
||||
var pagedJobs = await _unitOfWork.Jobs.GetPagedAsync(
|
||||
pageNumber: 1,
|
||||
pageSize: 25,
|
||||
j => j.Status == JobStatus.InPreparation,
|
||||
j => j.Customer
|
||||
);
|
||||
```
|
||||
|
||||
## Important Domain Concepts
|
||||
|
||||
### Job Lifecycle
|
||||
|
||||
Jobs progress through 16 statuses:
|
||||
1. **Pending** → Initial state
|
||||
2. **Quoted** → Quote generated
|
||||
3. **Approved** → Customer approved
|
||||
4. **InPreparation** → Job prep started
|
||||
5. **Sandblasting** → Surface prep
|
||||
6. **MaskingTaping** → Masking areas
|
||||
7. **Cleaning** → Pre-coat cleaning
|
||||
8. **InOven** → Pre-heating
|
||||
9. **Coating** → Applying powder
|
||||
10. **Curing** → Heat curing
|
||||
11. **QualityCheck** → Inspection
|
||||
12. **Completed** → Work finished
|
||||
13. **ReadyForPickup** → Awaiting customer
|
||||
14. **Delivered** → Job delivered
|
||||
15. **OnHold** → Paused
|
||||
16. **Cancelled** → Cancelled
|
||||
|
||||
**Job Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI)
|
||||
|
||||
### Customer Types
|
||||
|
||||
- **Commercial**: B2B customers with pricing tiers, credit limits
|
||||
- **Non-Commercial**: Individual customers, typically simpler pricing
|
||||
|
||||
### Inventory Management
|
||||
|
||||
**Transaction Types**: Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial
|
||||
- All transactions tracked in `InventoryTransaction` entity
|
||||
- Reorder points trigger low-stock alerts
|
||||
|
||||
### Equipment & Maintenance
|
||||
|
||||
**Equipment Status**: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired
|
||||
**Maintenance Priority**: Low, Normal, High, Critical
|
||||
**Maintenance Status**: Scheduled, InProgress, Completed, Cancelled, Overdue
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Web Application (src/PowderCoating.Web/appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=PowderCoatingDb;Trusted_Connection=true;MultipleActiveResultSets=true;TrustServerCertificate=true"
|
||||
},
|
||||
"AppSettings": {
|
||||
"CompanyName": "Powder Coating Logix",
|
||||
"DefaultQuoteValidityDays": 30,
|
||||
"DefaultPaymentTerms": "Net 30",
|
||||
"TaxRate": 0.0,
|
||||
"Currency": "USD",
|
||||
"TrialPeriodDays": 7,
|
||||
"QuoteApprovalTokenDays": 30
|
||||
},
|
||||
"AI": {
|
||||
"Anthropic": {
|
||||
"ApiKey": "your-anthropic-api-key-here"
|
||||
}
|
||||
},
|
||||
"SendGrid": { ... },
|
||||
"Stripe": { ... },
|
||||
"Storage": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**AI uses Anthropic Codex Sonnet 4.6** (`Codex-sonnet-4-6`) — NOT OpenAI. The `AI:Anthropic:ApiKey` config key is what the AI photo quoting and AI scheduling services read.
|
||||
|
||||
### API (src/PowderCoating.Api/appsettings.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"JwtSettings": {
|
||||
"SecretKey": "CHANGE-THIS-TO-YOUR-OWN-SECRET-KEY-AT-LEAST-32-CHARACTERS",
|
||||
"Issuer": "PowderCoatingAPI",
|
||||
"Audience": "PowderCoatingMobileApp",
|
||||
"ExpirationMinutes": 1440
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Launch Settings (src/PowderCoating.Web/Properties/launchSettings.json)
|
||||
|
||||
Default ports:
|
||||
- HTTPS: 58461
|
||||
- HTTP: 58462
|
||||
|
||||
## Authentication & Authorization
|
||||
|
||||
### System Roles
|
||||
- **SuperAdmin**: Platform-wide access, sees all companies and deleted records
|
||||
- **Administrator**: Company admin
|
||||
- **Manager**: Operations management
|
||||
- **Employee**: Create/edit jobs and quotes
|
||||
- **ShopFloor**: Update job status
|
||||
- **ReadOnly**: View-only access
|
||||
|
||||
### Custom Authorization Policies
|
||||
|
||||
Defined in `PowderCoating.Shared/Constants/AppConstants.cs`:
|
||||
- `RequireAdministratorRole`
|
||||
- `CanManageJobs`
|
||||
- `CanManageInventory`
|
||||
- `CanManageUsers`
|
||||
- `CanViewData`
|
||||
|
||||
Apply with `[Authorize(Policy = "PolicyName")]` on controllers/actions.
|
||||
|
||||
### JWT Authentication (API Only)
|
||||
|
||||
API uses JWT Bearer tokens. Web uses cookie-based Identity authentication.
|
||||
|
||||
## AutoMapper Configuration
|
||||
|
||||
AutoMapper is registered as singleton in `Program.cs`:
|
||||
```csharp
|
||||
builder.Services.AddSingleton(provider => new MapperConfiguration(cfg =>
|
||||
{
|
||||
cfg.AddMaps(typeof(ApplicationAssemblyMarker).Assembly);
|
||||
}).CreateMapper());
|
||||
```
|
||||
|
||||
All profiles in `Application/Mappings/` are auto-discovered. Profiles include reverse mappings for entity ↔ DTO conversion.
|
||||
|
||||
## Logging
|
||||
|
||||
Serilog configured to write:
|
||||
- Console (structured logs)
|
||||
- File: `logs/powdercoating-{Date}.txt` (rolling daily)
|
||||
|
||||
Access via constructor injection:
|
||||
```csharp
|
||||
private readonly ILogger<ExampleController> _logger;
|
||||
```
|
||||
|
||||
## Common Development Tasks
|
||||
|
||||
### Adding a New Entity
|
||||
|
||||
1. Create entity class in `Core/Entities/` inheriting from `BaseEntity`
|
||||
2. Add DbSet to `ApplicationDbContext`
|
||||
3. Register repository property in `IUnitOfWork` interface
|
||||
4. Add lazy-loaded property in `UnitOfWork` implementation
|
||||
5. Create migration: `dotnet ef migrations add AddEntityName --project ../PowderCoating.Infrastructure`
|
||||
6. Apply migration: `dotnet ef database update --project ../PowderCoating.Infrastructure`
|
||||
|
||||
### Adding a New Controller
|
||||
|
||||
1. Create DTOs in `Application/DTOs/`
|
||||
2. Create AutoMapper profile in `Application/Mappings/`
|
||||
3. Create controller in `Web/Controllers/`
|
||||
4. Create views in `Web/Views/[ControllerName]/`
|
||||
5. Add navigation link in `Views/Shared/_Layout.cshtml`
|
||||
|
||||
### Working with Soft Deletes
|
||||
|
||||
```csharp
|
||||
// Soft delete (sets IsDeleted = true)
|
||||
await _unitOfWork.Customers.SoftDeleteAsync(id);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Physical delete (use sparingly)
|
||||
await _unitOfWork.Customers.DeleteAsync(entity);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Include deleted records in query
|
||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync(ignoreQueryFilters: true);
|
||||
```
|
||||
|
||||
### Bypassing Multi-Tenancy Filters
|
||||
|
||||
Only for SuperAdmin users:
|
||||
```csharp
|
||||
// See all companies' data
|
||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(ignoreQueryFilters: true);
|
||||
```
|
||||
|
||||
## Implemented Modules
|
||||
|
||||
All modules below are fully implemented with controllers, views, and migrations applied.
|
||||
|
||||
### Operations
|
||||
- **Jobs** — full lifecycle (16 statuses), worker assignment, time entries, rework tracking, shop access codes, job templates
|
||||
- **Quotes** — multi-item pricing engine, AI Photo Quoting (Anthropic Codex Sonnet 4.6), quote-to-job conversion, customer approval portal, online payment
|
||||
- **Invoices** — create from job, partial payments, voids, PDF download, email send; 1:1 Job→Invoice enforced by unique index
|
||||
- **Deposits** — record against customer/job/quote; auto-applied to invoices on creation; receipt PDF via QuestPDF
|
||||
- **Customers** — commercial and non-commercial types, pricing tiers, tax exempt flag + certificate upload, credit limits
|
||||
- **Oven Scheduler** — batch jobs into named ovens, capacity planning, suggested batches
|
||||
|
||||
### Inventory & Purchasing
|
||||
- **Inventory** — stock tracking, transactions, reorder alerts, powder coverage/efficiency fields
|
||||
- **Vendors** — supplier management, payment terms, linked to inventory items
|
||||
- **Purchase Orders** — create/submit/receive POs, convert to vendor bills
|
||||
- **Accounts Payable** — vendor bills, AP ledger, payment tracking
|
||||
|
||||
### Shop Management
|
||||
- **Shop Workers** — roles (Coater, Sandblaster, etc.), assignment to jobs and maintenance tasks
|
||||
- **Equipment & Maintenance** — equipment status lifecycle, scheduled/completed maintenance records
|
||||
- **Catalog Items** — pre-priced service catalog with default prices
|
||||
- **Pricing Tiers** — customer discount tiers; use `CompanyAdminOnly` policy (not `RequireAdministratorRole`)
|
||||
|
||||
### Billing & Payments
|
||||
- **Stripe** — subscription plans, checkout sessions, customer portal, webhooks (`/stripe/webhook`)
|
||||
- **Stripe Connect** — embedded payments, OAuth flow for tenant onboarding
|
||||
- **Twilio SMS** — `ISmsService` fully implemented; webhook at `POST /Webhooks/TwilioSms`
|
||||
|
||||
### Platform (SuperAdmin only)
|
||||
- **Platform Users** — create/manage SuperAdmin accounts
|
||||
- **Companies** — view/manage all tenant companies
|
||||
- **Seed Data** — manual seeding via Platform Management UI (not automatic)
|
||||
- **Subscription Plans** — `SubscriptionPlanConfig` controls per-plan limits and pricing
|
||||
|
||||
### Other
|
||||
- **Help Center** — 14 fully-written articles at `Views/Help/`
|
||||
- **Setup Wizard** — 10-step onboarding wizard at `SetupWizardController`
|
||||
- **Reports** — 24 report actions including P&L, AR Aging, Powder Usage, Job Cycle Time, PDF exports
|
||||
- **Gift Certificates** — issue, redeem, track balance
|
||||
- **Announcements** — platform-wide announcements to tenants
|
||||
|
||||
### Key Pricing Rules
|
||||
- Custom powder (no inventory item + `PowderToOrder` > 0): charge for the **full ordered quantity**, not just calculated usage
|
||||
- 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
|
||||
- Sidebar footer always shows PCL logo linking to `http://www.powdercoatinglogix.com`
|
||||
- Tenant companies can upload their own logo (stored in Azure Blob `companylogos` container); it replaces the PCL logo in the sidebar header
|
||||
|
||||
## Known Issues
|
||||
|
||||
- Entity Framework warnings about global query filters on related entities (non-critical, informational only)
|
||||
|
||||
## File Upload Configuration
|
||||
|
||||
Limits defined in `AppConstants.cs`:
|
||||
- Max file size: 10 MB
|
||||
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- **Unit Tests**: Test business logic in isolation
|
||||
- **Integration Tests**: Test full request pipeline with test database
|
||||
- Use xUnit framework
|
||||
- Mock `IUnitOfWork` in unit tests
|
||||
|
||||
## Extending the System
|
||||
|
||||
### Adding AI Features
|
||||
|
||||
AI uses Anthropic Codex Sonnet 4.6 via `IAiQuoteService`. Configure the key under `AI:Anthropic:ApiKey` in `appsettings.json`.
|
||||
1. Create service interface in `Application/Interfaces/`
|
||||
2. Implement in `Infrastructure/Services/` calling the Anthropic client
|
||||
3. Inject into controllers via DI
|
||||
|
||||
### SignalR Hubs
|
||||
|
||||
Two hubs are already implemented and mapped in `Program.cs`:
|
||||
- `NotificationHub` → `/hubs/notifications` (company-scoped push notifications)
|
||||
- `ShopHub` → `/hubs/shop` (real-time shop floor updates)
|
||||
|
||||
To add a new hub:
|
||||
1. Create hub class in `Web/Hubs/`
|
||||
2. Map hub in `Program.cs`: `app.MapHub<YourHub>("/hubpath")`
|
||||
3. Use JavaScript client in views to connect
|
||||
|
||||
### Adding API Endpoints
|
||||
|
||||
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute
|
||||
2. Return `ActionResult<T>` types
|
||||
3. Use `[Authorize]` for protected endpoints
|
||||
4. Document with XML comments for Swagger
|
||||
|
||||
## Project Dependencies
|
||||
|
||||
Key NuGet packages:
|
||||
- **AutoMapper 16.0.0**: Entity-to-DTO mapping
|
||||
- **Entity Framework Core 8.0.11**: ORM and database access
|
||||
- **Serilog.AspNetCore 8.0.3**: Structured logging
|
||||
- **Microsoft.AspNetCore.Identity.UI 8.0.11**: Authentication
|
||||
- **Swashbuckle.AspNetCore 7.2.0**: API documentation (API project)
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Password requirements: 8+ chars, uppercase, lowercase, digit
|
||||
- HTTPS enforced in production
|
||||
- SQL injection prevented by EF Core parameterization
|
||||
- XSS protection via Razor encoding
|
||||
- CSRF tokens on all forms (automatic with ASP.NET Core)
|
||||
- Sensitive settings (connection strings, API keys) should use User Secrets in development and Azure Key Vault in production
|
||||
|
||||
## Active design work
|
||||
A visual redesign is in progress. If the user asks about UI changes, dashboard/jobs/board styling, or the new design tokens, read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/AGENTS.md` for that work.
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
NCalc2 2.1.0 -> Antlr4 4.6.4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 pulls in
|
||||
old package versions that trigger NU1605 downgrade warnings when publishing for linux-x64.
|
||||
These are harmless false positives — .NET 8 supplies all of these natively at runtime.
|
||||
Suppressing NU1605 here is cleaner than pinning every affected transitive package individually.
|
||||
-->
|
||||
<NoWarn>$(NoWarn);NU1605</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,403 @@
|
||||
-- =============================================================================
|
||||
-- Company Data Purge Script
|
||||
-- Removes financial, job, and quote data created before a cutoff date.
|
||||
-- Customers and Vendors are always preserved.
|
||||
--
|
||||
-- WHAT THIS DELETES (for records where CreatedAt < @CutoffDate):
|
||||
-- • Journal entries & bank reconciliations
|
||||
-- • Bills, bill payments, purchase orders, vendor credits, expenses
|
||||
-- • Invoices, invoice payments, credit memos, refunds, deposits
|
||||
-- • Jobs and all child records (items, coats, prep, notes, photos, etc.)
|
||||
-- • Quotes and all child records
|
||||
--
|
||||
-- WHAT THIS KEEPS (always):
|
||||
-- • Customers, customer notes, customer contacts, preferred powders
|
||||
-- • Vendors
|
||||
-- • Inventory items and inventory transactions
|
||||
-- • Equipment, catalog items, pricing tiers
|
||||
-- • Company settings and configuration
|
||||
-- • Any record with CreatedAt >= @CutoffDate
|
||||
--
|
||||
-- INSTRUCTIONS:
|
||||
-- 1. Set @CompanyId — find it with: SELECT Id, Name FROM Companies
|
||||
-- 2. Set @CutoffDate — records created BEFORE this date are deleted
|
||||
-- 3. Run with @DryRun = 1 first and review the row counts printed
|
||||
-- 4. Back up the database before setting @DryRun = 0
|
||||
-- 5. Set @DryRun = 0 and run again to apply
|
||||
-- =============================================================================
|
||||
|
||||
DECLARE @CutoffDate DATE = '2026-01-01'; -- Delete records created BEFORE this date
|
||||
DECLARE @CompanyId INT = 0; -- !! Set to your company ID before running
|
||||
DECLARE @DryRun BIT = 1; -- 1 = preview counts only | 0 = apply deletes
|
||||
|
||||
-- =============================================================================
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF @CompanyId = 0
|
||||
BEGIN
|
||||
RAISERROR('ERROR: Set @CompanyId before running this script. Run: SELECT Id, Name FROM Companies', 16, 1);
|
||||
RETURN;
|
||||
END
|
||||
|
||||
PRINT '============================================================';
|
||||
PRINT 'Purge run at: ' + CONVERT(NVARCHAR, GETDATE(), 120);
|
||||
PRINT 'Company ID : ' + CAST(@CompanyId AS NVARCHAR);
|
||||
PRINT 'Cutoff date : ' + CAST(@CutoffDate AS NVARCHAR) + ' (records BEFORE this date)';
|
||||
PRINT 'Dry run : ' + CASE @DryRun WHEN 1 THEN 'YES — no changes will be made' ELSE 'NO — deletes will be applied' END;
|
||||
PRINT '============================================================';
|
||||
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 1 — JOURNAL ENTRIES & GL
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 1: Journal Entries & GL ---';
|
||||
|
||||
-- Null the self-referential ReversalOfId FK before deleting
|
||||
UPDATE JournalEntries
|
||||
SET ReversalOfId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND ReversalOfId IN (SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
DELETE FROM JournalEntryLines
|
||||
WHERE JournalEntryId IN (
|
||||
SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JournalEntryLines deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JournalEntries
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'JournalEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM BankReconciliations
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'BankReconciliations deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 2 — BILLS, PURCHASE ORDERS & EXPENSES
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 2: Bills, Purchase Orders & Expenses ---';
|
||||
|
||||
-- Vendor credits (must come before Bills because VendorCreditApplications references both)
|
||||
DELETE FROM VendorCreditApplications
|
||||
WHERE VendorCreditId IN (
|
||||
SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'VendorCreditApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM VendorCreditLineItems
|
||||
WHERE VendorCreditId IN (
|
||||
SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'VendorCreditLineItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM VendorCredits
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'VendorCredits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Bills
|
||||
DELETE FROM BillPayments
|
||||
WHERE BillId IN (
|
||||
SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'BillPayments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM BillLineItems
|
||||
WHERE BillId IN (
|
||||
SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'BillLineItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM Bills
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'Bills deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Purchase orders
|
||||
DELETE FROM PurchaseOrderItems
|
||||
WHERE PurchaseOrderId IN (
|
||||
SELECT Id FROM PurchaseOrders WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'PurchaseOrderItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM PurchaseOrders
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'PurchaseOrders deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Expenses
|
||||
DELETE FROM Expenses
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'Expenses deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 3 — INVOICES, PAYMENTS & DEPOSITS
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 3: Invoices, Payments & Deposits ---';
|
||||
|
||||
-- CreditMemos: NULL the OriginalInvoiceId FK before deleting the invoice it points to
|
||||
UPDATE CreditMemos
|
||||
SET OriginalInvoiceId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND OriginalInvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
DELETE FROM CreditMemoApplications
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate)
|
||||
OR CreditMemoId IN (
|
||||
SELECT Id FROM CreditMemos WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'CreditMemoApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM CreditMemos
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'CreditMemos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Refunds and gift-cert redemptions tied to deleted invoices
|
||||
DELETE FROM Refunds
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'Refunds deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM GiftCertificateRedemptions
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'GiftCertificateRedemptions deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Payments
|
||||
DELETE FROM Payments
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'Payments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- InvoiceItems (NULL SourceJobItemId on any invoice items that survive but point at deleted jobs)
|
||||
UPDATE InvoiceItems
|
||||
SET SourceJobItemId = NULL
|
||||
WHERE SourceJobItemId IN (
|
||||
SELECT ji.Id FROM JobItems ji
|
||||
INNER JOIN Jobs j ON ji.JobId = j.Id
|
||||
WHERE j.CompanyId = @CompanyId AND j.CreatedAt < @CutoffDate);
|
||||
|
||||
DELETE FROM InvoiceItems
|
||||
WHERE InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'InvoiceItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Deposits: clear the AppliedToInvoiceId FK before deleting the invoices
|
||||
UPDATE Deposits
|
||||
SET AppliedToInvoiceId = NULL,
|
||||
AppliedDate = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND AppliedToInvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
-- Now delete deposits that were created before the cutoff
|
||||
DELETE FROM Deposits
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'Deposits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Notification logs referencing deleted invoices
|
||||
UPDATE NotificationLogs
|
||||
SET InvoiceId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND InvoiceId IN (
|
||||
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
DELETE FROM Invoices
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'Invoices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 4 — JOBS
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 4: Jobs ---';
|
||||
|
||||
-- NULL FKs in other tables that point to jobs/job-items being deleted --
|
||||
|
||||
-- BillLineItems.JobId (bill survived but referenced a deleted job)
|
||||
UPDATE BillLineItems
|
||||
SET JobId = NULL
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
-- Expenses.JobId
|
||||
UPDATE Expenses
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
-- Appointments.JobId
|
||||
UPDATE Appointments
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
-- Deposits.JobId (NoAction FK — must NULL before deleting job)
|
||||
UPDATE Deposits
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
-- NotificationLogs.JobId
|
||||
UPDATE NotificationLogs
|
||||
SET JobId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
-- OvenBatchItems: delete before OvenBatches and before JobItems
|
||||
DELETE FROM OvenBatchItems
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'OvenBatchItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Clean up now-empty OvenBatches (batches belonging to this company with no remaining items)
|
||||
DELETE FROM OvenBatches
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND CreatedAt < @CutoffDate
|
||||
AND Id NOT IN (SELECT DISTINCT OvenBatchId FROM OvenBatchItems);
|
||||
PRINT 'OvenBatches deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ReworkRecords (JobId required FK; ReworkJobId optional)
|
||||
DELETE FROM ReworkRecords
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate)
|
||||
OR ReworkJobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'ReworkRecords deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- JobItem children (coats and prep services)
|
||||
DELETE FROM JobItemCoats
|
||||
WHERE JobItemId IN (
|
||||
SELECT Id FROM JobItems WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate));
|
||||
PRINT 'JobItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobItemPrepServices
|
||||
WHERE JobItemId IN (
|
||||
SELECT Id FROM JobItems WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate));
|
||||
PRINT 'JobItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobItems
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JobItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- Job metadata tables
|
||||
DELETE FROM JobChangeHistories
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JobChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobTimeEntries
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JobTimeEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobPhotos
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JobPhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobNotes
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JobNotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobStatusHistory
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JobStatusHistory deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM JobDailyPriorities
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'JobDailyPriorities deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM PowderUsageLogs
|
||||
WHERE JobId IN (
|
||||
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'PowderUsageLogs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM AiItemPredictions
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'AiItemPredictions deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM Jobs
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'Jobs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SECTION 5 — QUOTES
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '--- Section 5: Quotes ---';
|
||||
|
||||
-- NULL FKs that point to quotes being deleted
|
||||
UPDATE Deposits
|
||||
SET QuoteId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
UPDATE NotificationLogs
|
||||
SET QuoteId = NULL
|
||||
WHERE CompanyId = @CompanyId
|
||||
AND QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
|
||||
-- QuoteItem children
|
||||
DELETE FROM QuoteItemCoats
|
||||
WHERE QuoteItemId IN (
|
||||
SELECT Id FROM QuoteItems WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate));
|
||||
PRINT 'QuoteItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuoteItemPrepServices
|
||||
WHERE QuoteItemId IN (
|
||||
SELECT Id FROM QuoteItems WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate));
|
||||
PRINT 'QuoteItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuoteItems
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'QuoteItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuoteChangeHistories
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'QuoteChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM QuotePhotos
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'QuotePhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- QuotePrepServices are scoped to the quote's company via the quote FK, not directly
|
||||
DELETE FROM QuotePrepServices
|
||||
WHERE QuoteId IN (
|
||||
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate);
|
||||
PRINT 'QuotePrepServices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
DELETE FROM Quotes
|
||||
WHERE CompanyId = @CompanyId AND CreatedAt < @CutoffDate;
|
||||
PRINT 'Quotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||
|
||||
-- ===========================================================================
|
||||
-- SUMMARY & COMMIT / ROLLBACK
|
||||
-- ===========================================================================
|
||||
PRINT '';
|
||||
PRINT '============================================================';
|
||||
|
||||
IF @DryRun = 1
|
||||
BEGIN
|
||||
PRINT 'DRY RUN complete — rolling back. No data was changed.';
|
||||
PRINT 'Set @DryRun = 0 and run again to apply the deletes.';
|
||||
ROLLBACK TRANSACTION;
|
||||
END
|
||||
ELSE
|
||||
BEGIN
|
||||
PRINT 'Purge complete — committing.';
|
||||
COMMIT TRANSACTION;
|
||||
END
|
||||
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
||||
public bool HasCurrentSmsAgreement { get; set; }
|
||||
public string SmsTermsVersion { get; set; } = string.Empty;
|
||||
|
||||
// Timeclock settings
|
||||
public bool TimeclockEnabled { get; set; }
|
||||
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||
public int? TimeclockAutoClockOutHours { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
|
||||
public class UpdateTimeclockSettingsDto
|
||||
{
|
||||
public bool TimeclockEnabled { get; set; }
|
||||
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
|
||||
public int? TimeclockAutoClockOutHours { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Company;
|
||||
|
||||
// ============================================================================
|
||||
// LIST DTO - For Company Settings tab table
|
||||
// ============================================================================
|
||||
public class CustomItemTemplateListDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public int FieldCount { get; set; }
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FULL DTO - For Edit modal and formula evaluation
|
||||
// ============================================================================
|
||||
public class CustomItemTemplateDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CREATE DTO
|
||||
// ============================================================================
|
||||
public class CreateCustomItemTemplateDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"FixedRate" or "SurfaceAreaSqFt"</summary>
|
||||
[Required]
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||
[Required]
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE DTO
|
||||
// ============================================================================
|
||||
public class UpdateCustomItemTemplateDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
[Required]
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Existing diagram path — kept if no new file is uploaded.</summary>
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
|
||||
// ============================================================================
|
||||
public class CustomItemTemplatePickerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI GENERATION DTOs
|
||||
// ============================================================================
|
||||
public class GenerateFormulaFromAiRequest
|
||||
{
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GenerateFormulaFromAiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? OutputMode { get; set; }
|
||||
public string? FieldsJson { get; set; }
|
||||
public string? Formula { get; set; }
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Reasoning { get; set; }
|
||||
|
||||
/// <summary>Result of running the formula with any sample values found in the description.</summary>
|
||||
public decimal? VerificationResult { get; set; }
|
||||
public string? VerificationInputs { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FORMULA EVALUATION DTOs
|
||||
// ============================================================================
|
||||
public class EvaluateFormulaRequest
|
||||
{
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}</summary>
|
||||
[Required]
|
||||
public string VariablesJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public class EvaluateFormulaResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public decimal? Result { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace PowderCoating.Application.DTOs.Company;
|
||||
|
||||
// ── Browse / card display ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lean DTO for the community library browse grid card.</summary>
|
||||
public class FormulaLibraryCardDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string? Tags { get; set; }
|
||||
public string? IndustryHint { get; set; }
|
||||
public string SourceCompanyName { get; set; } = string.Empty;
|
||||
public int ImportCount { get; set; }
|
||||
public DateTime SharedAt { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
|
||||
/// <summary>Non-null when this formula was derived from another library entry.</summary>
|
||||
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||
public string? InspiredByName { get; set; }
|
||||
public string? InspiredByCompanyName { get; set; }
|
||||
|
||||
/// <summary>True when the current company has already imported this entry.</summary>
|
||||
public bool AlreadyImported { get; set; }
|
||||
|
||||
/// <summary>True when this formula was shared by the current browsing company.</summary>
|
||||
public bool IsOwnFormula { get; set; }
|
||||
|
||||
/// <summary>Total thumbs-up votes across all companies.</summary>
|
||||
public int ThumbsUp { get; set; }
|
||||
|
||||
/// <summary>Total thumbs-down votes across all companies.</summary>
|
||||
public int ThumbsDown { get; set; }
|
||||
|
||||
/// <summary>The current browsing company's vote: true = up, false = down, null = no vote.</summary>
|
||||
public bool? MyVote { get; set; }
|
||||
}
|
||||
|
||||
// ── Full detail (import preview modal) ────────────────────────────────────
|
||||
|
||||
/// <summary>Full DTO used in the import preview modal — shows fields and formula.</summary>
|
||||
public class FormulaLibraryDetailDto : FormulaLibraryCardDto
|
||||
{
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int FieldCount { get; set; }
|
||||
}
|
||||
|
||||
// ── Share from Company Settings ───────────────────────────────────────────
|
||||
|
||||
/// <summary>Submitted when a company admin shares one of their templates to the community library.</summary>
|
||||
public class ShareFormulaRequest
|
||||
{
|
||||
public int CustomItemTemplateId { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public string? IndustryHint { get; set; }
|
||||
}
|
||||
|
||||
// ── Company Settings list view ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Status of a template relative to the community library, shown in Company Settings.</summary>
|
||||
public class FormulaLibraryStatusDto
|
||||
{
|
||||
/// <summary>The FormulaLibraryItem Id, if this template has ever been shared.</summary>
|
||||
public int? LibraryItemId { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
|
||||
/// <summary>Whether this template is eligible to be shared (original or modified import).</summary>
|
||||
public bool CanShare { get; set; }
|
||||
|
||||
/// <summary>Set when this template was imported; the name of the original library entry.</summary>
|
||||
public string? ImportedFromName { get; set; }
|
||||
public string? ImportedFromCompany { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
public class CustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string? LastName { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? ContactRole { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(LastName) ? FirstName : $"{FirstName} {LastName}";
|
||||
}
|
||||
|
||||
public class CreateCustomerContactDto
|
||||
{
|
||||
[Required(ErrorMessage = "First name is required.")]
|
||||
[StringLength(100)]
|
||||
[Display(Name = "First Name")]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Last Name")]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Job Title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[Display(Name = "Role")]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[EmailAddress]
|
||||
[StringLength(200)]
|
||||
[Display(Name = "Email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Mobile Phone")]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
[Display(Name = "Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateCustomerContactDto : CreateCustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
|
||||
public class CustomerTimelineEventDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string BadgeColor { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Subtitle { get; set; }
|
||||
public decimal? Amount { get; set; }
|
||||
public int? EntityId { get; set; }
|
||||
public string? LinkController { get; set; }
|
||||
public string? LinkAction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
|
||||
public class CustomerLifetimeStatsDto
|
||||
{
|
||||
public int TotalJobs { get; set; }
|
||||
public int ActiveJobs { get; set; }
|
||||
/// <summary>Sum of Total on non-voided invoices.</summary>
|
||||
public decimal TotalRevenue { get; set; }
|
||||
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
|
||||
public decimal TotalCollected { get; set; }
|
||||
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
|
||||
public decimal AverageJobValue { get; set; }
|
||||
public DateTime? LastJobDate { get; set; }
|
||||
public int? DaysSinceLastJob { get; set; }
|
||||
public int TotalQuotes { get; set; }
|
||||
public int TotalInvoices { get; set; }
|
||||
public decimal OpenBalance { get; set; }
|
||||
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
|
||||
public int? LastJobId { get; set; }
|
||||
}
|
||||
@@ -36,6 +36,16 @@ public class CustomerDto
|
||||
public bool NotifyBySms { get; set; }
|
||||
public DateTime? SmsConsentedAt { get; set; }
|
||||
public string? SmsConsentMethod { get; set; }
|
||||
|
||||
// CRM
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to address
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
}
|
||||
|
||||
public class CreateCustomerDto : IValidatableObject
|
||||
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
|
||||
[StringLength(2000)]
|
||||
public string? GeneralNotes { get; set; }
|
||||
|
||||
[Display(Name = "How did you find us?")]
|
||||
[StringLength(100)]
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address
|
||||
[Display(Name = "Ship-To Street Address")]
|
||||
[StringLength(500)]
|
||||
public string? ShipToAddress { get; set; }
|
||||
|
||||
[Display(Name = "City")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCity { get; set; }
|
||||
|
||||
[Display(Name = "State")]
|
||||
[StringLength(50)]
|
||||
public string? ShipToState { get; set; }
|
||||
|
||||
[Display(Name = "Zip Code")]
|
||||
[StringLength(20)]
|
||||
public string? ShipToZipCode { get; set; }
|
||||
|
||||
[Display(Name = "Country")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
[Display(Name = "Notify by Email")]
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ public class EquipmentDto
|
||||
public string StatusDisplay { get; set; } = string.Empty;
|
||||
public string? Location { get; set; }
|
||||
|
||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public DateTime? LastMaintenanceDate { get; set; }
|
||||
public DateTime? NextScheduledMaintenance { get; set; }
|
||||
public int? DaysUntilMaintenance { get; set; }
|
||||
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
|
||||
|
||||
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
||||
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||
|
||||
[Display(Name = "Last Maintenance Date")]
|
||||
public DateTime? LastMaintenanceDate { get; set; }
|
||||
|
||||
@@ -63,4 +63,22 @@ public class CustomerImportDto
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
[Name("LeadSource")]
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
[Name("ShipToAddress")]
|
||||
public string? ShipToAddress { get; set; }
|
||||
|
||||
[Name("ShipToCity")]
|
||||
public string? ShipToCity { get; set; }
|
||||
|
||||
[Name("ShipToState")]
|
||||
public string? ShipToState { get; set; }
|
||||
|
||||
[Name("ShipToZipCode")]
|
||||
public string? ShipToZipCode { get; set; }
|
||||
|
||||
[Name("ShipToCountry")]
|
||||
public string? ShipToCountry { get; set; }
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ public class InvoiceImportDto
|
||||
[Name("DueDate")]
|
||||
public DateTime? DueDate { get; set; }
|
||||
|
||||
[Name("Project Name", "ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("SubTotal")]
|
||||
public decimal SubTotal { get; set; }
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ public class JobImportDto
|
||||
[Name("CustomerName")]
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
// Optional short label for the job (maps directly to Job.Description).
|
||||
// When blank, the system falls back to SpecialInstructions, then "Imported job".
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("Status")]
|
||||
public string Status { get; set; } = "Pending";
|
||||
|
||||
@@ -44,6 +49,9 @@ public class JobImportDto
|
||||
[Name("SpecialInstructions")]
|
||||
public string? SpecialInstructions { get; set; }
|
||||
|
||||
[Name("ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ public class QuoteImportDto
|
||||
[Name("ExpirationDate")]
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
[Name("ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("Subtotal")]
|
||||
public decimal Subtotal { get; set; }
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ public class InventoryListDto
|
||||
public string? CategoryName { get; set; }
|
||||
public string Category { get; set; } = string.Empty; // Legacy field
|
||||
public string? ColorName { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public decimal QuantityOnHand { get; set; }
|
||||
public string UnitOfMeasure { get; set; } = "lbs";
|
||||
public decimal ReorderPoint { get; set; }
|
||||
|
||||
@@ -33,6 +33,10 @@ public class InvoiceDto
|
||||
public string? CustomerEmail { get; set; }
|
||||
public string? CustomerPhone { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
public string? CustomerAddress { get; set; }
|
||||
public string? CustomerCity { get; set; }
|
||||
public string? CustomerState { get; set; }
|
||||
public string? CustomerZipCode { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; }
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? PreparedById { get; set; }
|
||||
@@ -53,6 +57,7 @@ public class InvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? ExternalReference { get; set; }
|
||||
public int? SalesTaxAccountId { get; set; }
|
||||
public string? SalesTaxAccountName { get; set; }
|
||||
@@ -84,6 +89,7 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
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>
|
||||
@@ -101,6 +107,7 @@ public class UpdateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ public class JobDto
|
||||
public decimal DiscountValue { get; set; }
|
||||
public string? DiscountReason { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
@@ -113,6 +114,8 @@ public class JobListDto
|
||||
|
||||
public string? CustomerEmail { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -137,6 +140,13 @@ public class CreateJobDto
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -159,6 +169,7 @@ public class CreateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
@@ -208,6 +219,16 @@ public class UpdateJobDto
|
||||
[Display(Name = "Assigned Worker")]
|
||||
public string? AssignedUserId { get; set; }
|
||||
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -234,6 +255,7 @@ public class UpdateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
@@ -308,7 +330,11 @@ public class JobItemDto
|
||||
public bool IsGenericItem { get; set; }
|
||||
public bool IsLaborItem { get; set; }
|
||||
public bool IsSalesItem { get; set; }
|
||||
public bool IsAiItem { get; set; }
|
||||
public string? Sku { get; set; }
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
public List<JobItemCoatDto> Coats { get; set; } = new();
|
||||
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
||||
}
|
||||
@@ -381,6 +407,7 @@ public class JobItemCoatDto
|
||||
public decimal? PowderCostPerLb { get; set; }
|
||||
public decimal? PowderToOrder { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -389,7 +416,7 @@ public class CompleteJobDto
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public decimal? ActualTimeSpentHours { get; set; }
|
||||
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
|
||||
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
|
||||
public bool SendEmailToCustomer { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -400,10 +427,10 @@ public class SendJobSmsRequest
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// DTO for tracking actual powder usage per coat
|
||||
public class JobItemCoatUsageDto
|
||||
// DTO for tracking actual powder usage per inventory item (color) for the whole job
|
||||
public class JobPowderUsageDto
|
||||
{
|
||||
public int JobItemCoatId { get; set; }
|
||||
public int InventoryItemId { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; }
|
||||
}
|
||||
|
||||
@@ -468,6 +495,7 @@ public class ReworkRecordDto
|
||||
public decimal ActualReworkCost { get; set; }
|
||||
public bool IsBillableToCustomer { get; set; }
|
||||
public string? BillingNotes { get; set; }
|
||||
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||
|
||||
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
||||
public string StatusDisplay { get; set; } = string.Empty;
|
||||
@@ -493,6 +521,11 @@ public class CreateReworkRecordDto
|
||||
public decimal EstimatedReworkCost { get; set; }
|
||||
public bool IsBillableToCustomer { get; set; }
|
||||
public string? BillingNotes { get; set; }
|
||||
|
||||
// Rework job creation (opt-in)
|
||||
public bool CreateReworkJob { get; set; }
|
||||
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
|
||||
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateReworkRecordDto
|
||||
|
||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Items
|
||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
@@ -475,6 +478,11 @@ public class QuoteItemDto
|
||||
|
||||
public bool IsAiItem { get; set; }
|
||||
|
||||
// Custom formula item
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
|
||||
// Cost breakdown snapshot
|
||||
public decimal ItemMaterialCost { get; set; }
|
||||
public decimal ItemLaborCost { get; set; }
|
||||
@@ -559,6 +567,11 @@ public class CreateQuoteItemDto
|
||||
|
||||
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
||||
public int? AiPredictionId { get; set; }
|
||||
|
||||
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -801,6 +814,7 @@ public class QuoteItemCoatDto
|
||||
public decimal CoatMaterialCost { get; set; }
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -873,4 +887,9 @@ public class QuotePricingResult
|
||||
|
||||
// Per-item results (same order as input items)
|
||||
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
||||
|
||||
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
|
||||
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
|
||||
public decimal CustomPowderOrderAmount { get; set; }
|
||||
public List<string> CustomPowderOrderColors { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
public bool AllowCustomFormulas { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
public bool AllowCustomFormulas { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Timeclock;
|
||||
|
||||
public class EmployeeClockEntryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string UserDisplayName { get; set; } = string.Empty;
|
||||
public DateTime ClockInTime { get; set; }
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
public decimal? HoursWorked { get; set; }
|
||||
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||
public string? Notes { get; set; }
|
||||
public bool IsOpen => ClockOutTime == null;
|
||||
}
|
||||
|
||||
public class ClockInRequest
|
||||
{
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class ClockOutRequest
|
||||
{
|
||||
public int EntryId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
|
||||
/// The server determines whether to clock in or clock out based on the employee's open entry.
|
||||
/// </summary>
|
||||
public class KioskPunchRequest
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string Pin { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class EditClockEntryRequest
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime ClockInTime { get; set; }
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sent when an employee clicks Break or Lunch to pause their work segment.
|
||||
/// The server closes the current Work entry and opens a Break/Lunch entry.
|
||||
/// </summary>
|
||||
public class GoOnBreakRequest
|
||||
{
|
||||
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
|
||||
public ClockEntryType BreakType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
|
||||
public class ManualEntryRequest
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public DateTime ClockInTime { get; set; }
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
|
||||
public class KioskEmployeeDto
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Initials { get; set; } = string.Empty;
|
||||
/// <summary>True when the employee has an open clock entry right now.</summary>
|
||||
public bool IsClockedIn { get; set; }
|
||||
}
|
||||
@@ -125,6 +125,8 @@ public class CreateVendorDto
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
public List<int> CategoryIds { get; set; } = new();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -209,4 +211,6 @@ public class UpdateVendorDto
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
public List<int> CategoryIds { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ public class WizardProgressDto
|
||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
||||
|
||||
public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
|
||||
// Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
|
||||
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
|
||||
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface ICustomFormulaAiService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a NCalc formula, field list, and notes from a natural-language description
|
||||
/// and an optional diagram image. Returns a <see cref="GenerateFormulaFromAiResponse"/>
|
||||
/// ready to pre-fill the template editor.
|
||||
/// </summary>
|
||||
Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
|
||||
GenerateFormulaFromAiRequest request,
|
||||
byte[]? imageBytes = null,
|
||||
string? imageContentType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
|
||||
/// Safe server-side only — no user-controlled code execution.
|
||||
/// </summary>
|
||||
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then
|
||||
/// attempts a parse-only evaluation to catch syntax errors before the formula is saved.
|
||||
/// Returns the normalized formula string and a null error on success, or the original
|
||||
/// formula and an error message on failure.
|
||||
/// </summary>
|
||||
(string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the community formula library: sharing, unsharing, importing, and browsing.
|
||||
/// </summary>
|
||||
public interface IFormulaLibraryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all published library entries, with AlreadyImported populated for the given company.
|
||||
/// Optionally filters by search term, output mode, or industry hint.
|
||||
/// </summary>
|
||||
Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
|
||||
int companyId,
|
||||
string? search = null,
|
||||
string? outputMode = null,
|
||||
string? industryHint = null);
|
||||
|
||||
/// <summary>Full detail for the import preview modal, including field list and formula.</summary>
|
||||
Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a company template to the community library.
|
||||
/// If the template was previously shared and unpublished, re-publishes the existing row.
|
||||
/// Updates the library entry fields from the current template state on re-share.
|
||||
/// </summary>
|
||||
Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request);
|
||||
|
||||
/// <summary>Sets IsPublished = false. Existing imports are unaffected.</summary>
|
||||
Task UnshareAsync(int libraryItemId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Copies a library entry into the company's local CustomItemTemplate table.
|
||||
/// If the company already has an import record for this entry, returns the existing template id.
|
||||
/// </summary>
|
||||
Task<int> ImportAsync(int libraryItemId, int companyId, string userId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the library status for a given CustomItemTemplate — whether it is shared,
|
||||
/// eligible to be shared, and where it was imported from if applicable.
|
||||
/// </summary>
|
||||
Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
|
||||
/// when a source template's diagram is removed. Call from CompanySettingsController
|
||||
/// when a diagram is deleted or replaced.
|
||||
/// </summary>
|
||||
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
|
||||
|
||||
/// <summary>
|
||||
/// Records or toggles a thumbs-up/down vote from the given company.
|
||||
/// If the same vote already exists it is removed (toggle off).
|
||||
/// If the opposite vote exists it is replaced.
|
||||
/// Companies cannot rate their own formulas.
|
||||
/// Returns the updated counts for the library entry.
|
||||
/// </summary>
|
||||
Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
||||
int libraryItemId, int companyId, bool isPositive);
|
||||
}
|
||||
@@ -91,4 +91,11 @@ public interface INotificationService
|
||||
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||
/// </summary>
|
||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
|
||||
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||
/// when the reminder window opens. In-app bell notification is handled by the caller.
|
||||
/// </summary>
|
||||
Task NotifyAppointmentReminderAsync(Appointment appointment);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ public interface IPdfService
|
||||
CompanyInfoDto companyInfo,
|
||||
QuoteTemplateSettingsDto? template = null);
|
||||
|
||||
Task<byte[]> GeneratePackingSlipPdfAsync(
|
||||
InvoiceDto invoiceDto,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
|
||||
Task<byte[]> GeneratePurchaseOrderPdfAsync(
|
||||
PurchaseOrderDto po,
|
||||
byte[]? companyLogo,
|
||||
|
||||
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
|
||||
/// referenced by coats on the given quote, then links those coats to the new inventory records.
|
||||
/// Must be called after a quote transitions to Approved status.
|
||||
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
|
||||
/// </summary>
|
||||
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
||||
decimal invoiceTotal,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string invoiceNumber,
|
||||
int invoiceId);
|
||||
|
||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
||||
decimal depositAmount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string quoteNumber,
|
||||
int quoteId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Mappings;
|
||||
|
||||
public class CustomItemTemplateProfile : Profile
|
||||
{
|
||||
public CustomItemTemplateProfile()
|
||||
{
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplateListDto>()
|
||||
.ForMember(dest => dest.FieldCount,
|
||||
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplateDto>();
|
||||
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplatePickerDto>();
|
||||
|
||||
CreateMap<CreateCustomItemTemplateDto, CustomItemTemplate>();
|
||||
|
||||
CreateMap<UpdateCustomItemTemplateDto, CustomItemTemplate>()
|
||||
.ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
|
||||
|
||||
CreateMap<CustomItemTemplate, UpdateCustomItemTemplateDto>();
|
||||
}
|
||||
|
||||
private static int CountFields(string fieldsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||
? doc.RootElement.GetArrayLength()
|
||||
: 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
|
||||
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
|
||||
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
||||
: string.Empty));
|
||||
|
||||
// CustomerContact
|
||||
CreateMap<CustomerContact, CustomerContactDto>();
|
||||
CreateMap<CreateCustomerContactDto, CustomerContact>();
|
||||
CreateMap<UpdateCustomerContactDto, CustomerContact>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
|
||||
CreateMap<CustomerContact, UpdateCustomerContactDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Mappings;
|
||||
|
||||
public class FormulaLibraryProfile : Profile
|
||||
{
|
||||
public FormulaLibraryProfile()
|
||||
{
|
||||
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||
.ForMember(dest => dest.InspiredByName,
|
||||
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
|
||||
.ForMember(dest => dest.InspiredByCompanyName,
|
||||
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
|
||||
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
|
||||
|
||||
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
|
||||
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||
.ForMember(dest => dest.FieldCount,
|
||||
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||
}
|
||||
|
||||
private static int CountFields(string fieldsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||
? doc.RootElement.GetArrayLength()
|
||||
: 0;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
||||
|
||||
CreateMap<Invoice, InvoiceDto>()
|
||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||
? (s.Customer.IsCommercial
|
||||
? s.Customer.CompanyName
|
||||
@@ -29,6 +30,10 @@ public class InvoiceProfile : Profile
|
||||
: 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.CustomerAddress, o => o.MapFrom(s => s.Customer != null ? s.Customer.Address : null))
|
||||
.ForMember(d => d.CustomerCity, o => o.MapFrom(s => s.Customer != null ? s.Customer.City : null))
|
||||
.ForMember(d => d.CustomerState, o => o.MapFrom(s => s.Customer != null ? s.Customer.State : null))
|
||||
.ForMember(d => d.CustomerZipCode, o => o.MapFrom(s => s.Customer != null ? s.Customer.ZipCode : 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
|
||||
|
||||
@@ -196,7 +196,9 @@ public class JobProfile : Profile
|
||||
.ForMember(dest => dest.JobItemDescription,
|
||||
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
||||
.ForMember(dest => dest.ReworkJobNumber,
|
||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
|
||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
|
||||
.ForMember(dest => dest.ReworkPricingType,
|
||||
opt => opt.MapFrom(src => src.ReworkPricingType));
|
||||
|
||||
// Job → JobDto (rework fields)
|
||||
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
||||
|
||||
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
|
||||
.ReverseMap()
|
||||
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
|
||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
||||
@@ -190,7 +192,10 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
||||
|
||||
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
||||
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
|
||||
// is unreliable for ICollection<T> → List<T2> with different element types.
|
||||
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
||||
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||
|
||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||
|
||||
|
||||
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
|
||||
/// and <see cref="JobItemPrepService"/> entities.
|
||||
///
|
||||
/// Three source types are supported, each with a matching overload:
|
||||
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
|
||||
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
|
||||
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
|
||||
///
|
||||
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
|
||||
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
|
||||
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
|
||||
/// </summary>
|
||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> from a quote wizard DTO and a pre-calculated pricing result.
|
||||
/// Used when creating a job directly from the job form or from an approved quote via the wizard.
|
||||
/// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -35,13 +53,21 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from the coat DTOs in the quote wizard form.
|
||||
/// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
|
||||
/// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -62,7 +88,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -70,6 +97,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemPrepService"/> records (sandblasting, masking, etc.) from the
|
||||
/// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
|
||||
/// labor cost calculations and shop floor instructions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -85,6 +117,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
|
||||
/// exactly the amounts that were approved by the customer.
|
||||
/// The first coat's color/finish is promoted to the job item's top-level fields for quick display
|
||||
/// (details remain in the coat records).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -121,13 +160,22 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> if available,
|
||||
/// because the inventory record is the canonical source of truth for a product's appearance —
|
||||
/// the values typed into the quote form may be incomplete or informal.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -151,7 +199,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -160,6 +209,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -175,6 +227,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="JobItem"/> by cloning an existing one — used for job templates
|
||||
/// and rework duplication where an existing job line is reused on a new job.
|
||||
/// Prices are copied as-is from the source; the job controller is responsible for repricing
|
||||
/// if operating costs have changed since the original job was created.
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -207,13 +265,21 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones coat records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
|
||||
/// quantities may have been manually adjusted after initial calculation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -234,7 +300,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
@@ -242,6 +309,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
@@ -257,6 +327,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItem"/> creation paths.
|
||||
/// Centralised here so that adding a new field only requires one code change, not three.
|
||||
/// </summary>
|
||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItem
|
||||
@@ -288,11 +362,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
Complexity = seed.Complexity,
|
||||
AiTags = seed.AiTags,
|
||||
AiPredictionId = seed.AiPredictionId,
|
||||
IsCustomFormulaItem = seed.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = seed.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
|
||||
/// </summary>
|
||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItemCoat
|
||||
@@ -310,11 +390,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
|
||||
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
|
||||
/// can safely iterate without a null check.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return seeds?
|
||||
@@ -330,6 +416,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
|
||||
/// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
|
||||
///
|
||||
/// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
|
||||
///
|
||||
/// Industry defaults are applied when catalog data is missing:
|
||||
/// - Coverage: 30 sqft/lb (typical for standard powder at 2–3 mil DFT)
|
||||
/// - Transfer efficiency: 65% (industry average for electrostatic spray)
|
||||
/// These are conservative defaults that slightly overestimate powder needed — intentional,
|
||||
/// so the shop doesn't run short on a job.
|
||||
/// </summary>
|
||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||
{
|
||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||
@@ -343,6 +441,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
|
||||
/// <see cref="InventoryItem"/>'s values over whatever was typed into the quote form.
|
||||
/// The inventory record is the canonical source of truth — the form values are used as a fallback
|
||||
/// only when no inventory item is linked (e.g. custom/one-off powder).
|
||||
/// </summary>
|
||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||
string? colorName,
|
||||
string? colorCode,
|
||||
@@ -355,6 +459,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate value object that normalises the three different source types
|
||||
/// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
|
||||
/// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
|
||||
/// </summary>
|
||||
private sealed class JobItemSeed
|
||||
{
|
||||
public string Description { get; init; } = string.Empty;
|
||||
@@ -383,8 +492,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public string? Complexity { get; init; }
|
||||
public string? AiTags { get; init; }
|
||||
public int? AiPredictionId { get; init; }
|
||||
public bool IsCustomFormulaItem { get; init; }
|
||||
public int? CustomItemTemplateId { get; init; }
|
||||
public string? FormulaFieldValuesJson { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemCoatSeed
|
||||
{
|
||||
public string CoatName { get; init; } = string.Empty;
|
||||
@@ -399,8 +512,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool NoExtraLayerCharge { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemPrepServiceSeed
|
||||
{
|
||||
public int PrepServiceId { get; init; }
|
||||
|
||||
@@ -217,6 +217,8 @@ public class PdfService : IPdfService
|
||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -609,6 +611,15 @@ public class PdfService : IPdfService
|
||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2753,4 +2764,187 @@ public class PdfService : IPdfService
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Packing Slip
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Generates a no-price packing slip PDF for the given invoice. Lists job items with
|
||||
/// description, color, and quantity only — no unit prices or totals. Intended for
|
||||
/// physical pickup/delivery paperwork where pricing should not be visible.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GeneratePackingSlipPdfAsync(
|
||||
InvoiceDto invoiceDto,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accentColor = "#1e40af"; // blue
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
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.Header().Element(c => ComposePackingSlipHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||
page.Content().Element(c => ComposePackingSlipContent(c, invoiceDto, accentColor));
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span("PACKING SLIP | ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(" | Page ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.CurrentPageNumber().FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.Span(" of ").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
text.TotalPages().FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Header for the packing slip: company branding left, "PACKING SLIP" title + invoice/date info right.
|
||||
/// </summary>
|
||||
private void ComposePackingSlipHeader(IContainer container, byte[]? companyLogo, CompanyInfoDto companyInfo, string accentColor, InvoiceDto invoice)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
col.Item().Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(column =>
|
||||
{
|
||||
if (companyLogo != null && companyLogo.Length > 0)
|
||||
column.Item().MaxHeight(60).Image(companyLogo);
|
||||
else
|
||||
column.Item().Text(companyInfo.CompanyName).FontSize(18).Bold().FontColor(accentColor);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Address))
|
||||
column.Item().Text(companyInfo.Address).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
var cityLine = $"{companyInfo.City}{(!string.IsNullOrEmpty(companyInfo.City) && !string.IsNullOrEmpty(companyInfo.State) ? ", " : "")}{companyInfo.State} {companyInfo.ZipCode}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cityLine))
|
||||
column.Item().Text(cityLine).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
|
||||
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
|
||||
row.RelativeItem().AlignRight().Column(column =>
|
||||
{
|
||||
column.Item().Text("PACKING SLIP").FontSize(26).Bold().FontColor(accentColor);
|
||||
column.Item().Text($"Invoice #: {invoice.InvoiceNumber}").FontSize(9).Bold();
|
||||
column.Item().Text($"Date: {invoice.InvoiceDate:MMMM d, yyyy}").FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(invoice.JobNumber))
|
||||
column.Item().Text($"Job #: {invoice.JobNumber}").FontSize(9);
|
||||
});
|
||||
});
|
||||
|
||||
col.Item().PaddingVertical(4).LineHorizontal(1).LineColor(accentColor);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Body of the packing slip: customer info block, optional PO number, and an items table
|
||||
/// showing description, color, and quantity — no prices.
|
||||
/// </summary>
|
||||
private void ComposePackingSlipContent(IContainer container, InvoiceDto invoice, string accentColor)
|
||||
{
|
||||
container.Column(col =>
|
||||
{
|
||||
// Customer info
|
||||
col.Item().PaddingTop(12).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().Text("PREPARED FOR").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||||
c.Item().Text(invoice.CustomerName).Bold();
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerAddress))
|
||||
c.Item().Text(invoice.CustomerAddress).FontSize(9);
|
||||
var cityLine = $"{invoice.CustomerCity}{(!string.IsNullOrEmpty(invoice.CustomerCity) && !string.IsNullOrEmpty(invoice.CustomerState) ? ", " : "")}{invoice.CustomerState} {invoice.CustomerZipCode}".Trim();
|
||||
if (!string.IsNullOrWhiteSpace(cityLine))
|
||||
c.Item().Text(cityLine).FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPhone))
|
||||
c.Item().Text(FormatPhoneNumber(invoice.CustomerPhone)).FontSize(9);
|
||||
});
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||
{
|
||||
row.ConstantItem(160).AlignRight().Column(c =>
|
||||
{
|
||||
c.Item().Text("PURCHASE ORDER").FontSize(8).Bold().FontColor(Colors.Grey.Darken1);
|
||||
c.Item().Text(invoice.CustomerPO).Bold();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Items table
|
||||
col.Item().PaddingTop(16).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(5);
|
||||
cols.RelativeColumn(3);
|
||||
cols.RelativeColumn(1);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
h.Cell().Background(accentColor).Padding(5).Text("Description").FontColor(Colors.White).Bold().FontSize(9);
|
||||
h.Cell().Background(accentColor).Padding(5).Text("Color / Finish").FontColor(Colors.White).Bold().FontSize(9);
|
||||
h.Cell().Background(accentColor).Padding(5).AlignCenter().Text("Qty").FontColor(Colors.White).Bold().FontSize(9);
|
||||
});
|
||||
|
||||
var rowAlt = false;
|
||||
foreach (var item in invoice.InvoiceItems.OrderBy(i => i.DisplayOrder))
|
||||
{
|
||||
var bg = rowAlt ? Colors.Grey.Lighten4 : Colors.White;
|
||||
table.Cell().Background(bg).Padding(5).Column(c =>
|
||||
{
|
||||
c.Item().Text(item.Description).FontSize(9);
|
||||
if (!string.IsNullOrWhiteSpace(item.Notes))
|
||||
c.Item().Text(item.Notes).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
table.Cell().Background(bg).Padding(5).Text(item.ColorName ?? "—").FontSize(9);
|
||||
table.Cell().Background(bg).Padding(5).AlignCenter().Text(item.Quantity.ToString("G")).FontSize(9);
|
||||
rowAlt = !rowAlt;
|
||||
}
|
||||
});
|
||||
|
||||
// Notes (if any)
|
||||
if (!string.IsNullOrWhiteSpace(invoice.Notes))
|
||||
{
|
||||
col.Item().PaddingTop(16).Column(c =>
|
||||
{
|
||||
c.Item().Text("Notes").Bold().FontSize(9);
|
||||
c.Item().Text(invoice.Notes).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
}
|
||||
|
||||
// Received by signature line
|
||||
col.Item().PaddingTop(32).Row(row =>
|
||||
{
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
|
||||
c.Item().PaddingTop(2).Text("Received by / Date").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
row.ConstantItem(24);
|
||||
row.RelativeItem().Column(c =>
|
||||
{
|
||||
c.Item().BorderBottom(1).BorderColor(Colors.Grey.Darken1).PaddingBottom(2).Text(string.Empty);
|
||||
c.Item().PaddingTop(2).Text("Condition noted").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when a coat requires ordering custom powder that is not in inventory.
|
||||
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
|
||||
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
|
||||
/// </summary>
|
||||
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
|
||||
!coat.InventoryItemId.HasValue &&
|
||||
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
||||
/// path based on item type:
|
||||
@@ -288,6 +298,26 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
};
|
||||
}
|
||||
|
||||
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
|
||||
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
|
||||
// exactly like every other item type that uses ManualUnitPrice.
|
||||
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
|
||||
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
|
||||
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
var formulaUnitPrice = item.ManualUnitPrice.Value;
|
||||
var formulaTotal = formulaUnitPrice * item.Quantity;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = formulaTotal,
|
||||
UnitPrice = formulaUnitPrice,
|
||||
TotalPrice = formulaTotal
|
||||
};
|
||||
}
|
||||
|
||||
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
||||
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
@@ -312,6 +342,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
for (int i = 0; i < item.Coats.Count; i++)
|
||||
{
|
||||
// Custom powder material moves to the "Custom Powder Order" line item
|
||||
if (IsCustomPowderCoat(item.Coats[i])) continue;
|
||||
var coatResult = await CalculateCoatPriceAsync(
|
||||
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
||||
coatMaterialCost += coatResult.CoatMaterialCost;
|
||||
@@ -413,7 +445,9 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
for (int ci = 0; ci < item.Coats.Count; ci++)
|
||||
{
|
||||
var coat = item.Coats[ci];
|
||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
// Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
|
||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
|
||||
&& !IsCustomPowderCoat(coat))
|
||||
{
|
||||
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
||||
totalMaterialCost += coatResult.CoatMaterialCost;
|
||||
@@ -431,7 +465,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
||||
// Custom powder material moves to the "Custom Powder Order" line item; keep the labor
|
||||
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
|
||||
coatLaborCost = firstCoatResult.CoatLaborCost;
|
||||
totalLaborCost = coatLaborCost;
|
||||
}
|
||||
@@ -628,6 +663,49 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
// 4. TOTAL ITEMS SUBTOTAL
|
||||
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
||||
|
||||
// Powder-to-order costs are excluded from individual item prices and collected in a
|
||||
// "Custom Powder Order" line item added at save time. For live pricing previews (before
|
||||
// save), add them back here so the displayed total stays correct throughout the session.
|
||||
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
|
||||
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
|
||||
bool hasCustomPowderOrderItem = items.Any(i =>
|
||||
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
|
||||
decimal customPowderOrderAmount = 0m;
|
||||
var customPowderOrderColors = new List<string>();
|
||||
if (!hasCustomPowderOrderItem)
|
||||
{
|
||||
foreach (var item in items.Where(i => i.Coats != null))
|
||||
{
|
||||
foreach (var c in item.Coats!)
|
||||
{
|
||||
if (!c.InventoryItemId.HasValue &&
|
||||
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
|
||||
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
|
||||
if (!string.IsNullOrWhiteSpace(c.ColorName))
|
||||
customPowderOrderColors.Add(c.ColorName);
|
||||
}
|
||||
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
|
||||
{
|
||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
||||
if (invItem?.IsIncoming == true)
|
||||
{
|
||||
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
|
||||
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
||||
if (!string.IsNullOrWhiteSpace(colorName))
|
||||
customPowderOrderColors.Add(colorName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (customPowderOrderAmount > 0)
|
||||
{
|
||||
itemsSubtotal += customPowderOrderAmount;
|
||||
totalMaterialCosts += customPowderOrderAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
||||
// AI items already have oven cost baked into their AI-estimated price, so we only
|
||||
// charge the proportion of the oven that's attributable to non-AI items.
|
||||
@@ -806,7 +884,11 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
||||
LaborCosts = Math.Round(totalLaborCosts, 2),
|
||||
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
||||
ItemResults = itemResults
|
||||
ItemResults = itemResults,
|
||||
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
|
||||
CustomPowderOrderColors = customPowderOrderColors
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
|
||||
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
|
||||
///
|
||||
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
|
||||
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
|
||||
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
|
||||
///
|
||||
/// Key responsibilities:
|
||||
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
|
||||
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
|
||||
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
|
||||
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
|
||||
/// </summary>
|
||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
|
||||
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
|
||||
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
|
||||
/// </summary>
|
||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
|
||||
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
|
||||
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
|
||||
/// the user selects a catalog powder not yet in their inventory) and prep services.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
@@ -65,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||
|
||||
var dtoList = itemDtos.ToList();
|
||||
var items = new List<QuoteItem>();
|
||||
foreach (var itemDto in itemDtos)
|
||||
foreach (var itemDto in dtoList)
|
||||
{
|
||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||
@@ -77,9 +103,27 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
// Option B: auto-create the Custom Powder Order item only on first save.
|
||||
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
|
||||
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
|
||||
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
|
||||
if (!hasExistingCustomPowderOrder)
|
||||
{
|
||||
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
|
||||
if (customPowderItem != null)
|
||||
items.Add(customPowderItem);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
|
||||
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
|
||||
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
|
||||
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
|
||||
/// path to take, never HOW to compute the price.
|
||||
/// </summary>
|
||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||
{
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
@@ -98,6 +142,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
@@ -127,6 +179,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
ApplyCalculatedPricing(item, pricing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
|
||||
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
|
||||
/// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
|
||||
/// quote (deduplication). No inventory is created during quote save.
|
||||
/// </summary>
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||
@@ -137,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
||||
// Incoming-inventory creation is intentionally deferred to quote approval.
|
||||
// PowderCatalogItemId is persisted on the coat entity for later use.
|
||||
|
||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
@@ -158,6 +217,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return coats;
|
||||
}
|
||||
|
||||
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
|
||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||
@@ -175,6 +235,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
|
||||
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
|
||||
/// and calculation steps distinct and individually testable.
|
||||
/// </summary>
|
||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItem
|
||||
@@ -199,11 +264,15 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
IsAiItem = itemDto.IsAiItem,
|
||||
AiTags = itemDto.AiTags,
|
||||
AiPredictionId = itemDto.AiPredictionId,
|
||||
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = itemDto.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
|
||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItemCoat
|
||||
@@ -211,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
PowderCatalogItemId = coatDto.CatalogItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
@@ -219,12 +289,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = coatDto.PowderToOrder,
|
||||
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps the pricing result onto the quote item entity.
|
||||
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
|
||||
/// </summary>
|
||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||
{
|
||||
item.UnitPrice = pricing.UnitPrice;
|
||||
@@ -234,6 +309,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user changed the AI's surface area or price estimates before saving,
|
||||
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
|
||||
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
|
||||
/// and whether certain item types consistently need manual correction.
|
||||
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
|
||||
/// </summary>
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
@@ -247,18 +329,37 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
/// <summary>
|
||||
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
|
||||
/// Called at quote-approval time (not during quote save) so inventory records only appear
|
||||
/// when a job is actually going to be created. The caller groups coats by
|
||||
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
|
||||
/// duplicate records when the same powder appears on multiple items in the same quote.
|
||||
///
|
||||
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
|
||||
/// so the item always lands in the right bucket regardless of how many IsCoating categories
|
||||
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
|
||||
///
|
||||
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
|
||||
/// from the manufacturer product page. Best-effort — item is still created from catalog data
|
||||
/// if the AI call fails.
|
||||
/// </summary>
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
||||
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();
|
||||
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
|
||||
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
|
||||
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||
?? categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
@@ -363,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
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);
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
|
||||
item.Id, item.Name, 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);
|
||||
catalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
|
||||
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
|
||||
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
|
||||
/// on the first save only — Option B means the user owns the price after creation.
|
||||
///
|
||||
/// Coat types that qualify:
|
||||
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered)
|
||||
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
|
||||
/// pre-filled from catalog unit price (inventory creation deferred to approval)
|
||||
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
|
||||
/// </summary>
|
||||
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
||||
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
var colorNames = new List<string>();
|
||||
decimal totalCost = 0m;
|
||||
|
||||
foreach (var itemDto in itemDtos)
|
||||
{
|
||||
if (itemDto.Coats == null) continue;
|
||||
foreach (var coat in itemDto.Coats)
|
||||
{
|
||||
if (!coat.InventoryItemId.HasValue &&
|
||||
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
|
||||
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
|
||||
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||
colorNames.Add(coat.ColorName);
|
||||
}
|
||||
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
|
||||
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
|
||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||
if (invItem?.IsIncoming == true)
|
||||
{
|
||||
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
||||
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
||||
if (!string.IsNullOrWhiteSpace(colorName))
|
||||
colorNames.Add(colorName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCost <= 0) return null;
|
||||
|
||||
var uniqueColors = colorNames
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var description = uniqueColors.Any()
|
||||
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
||||
: "Custom Powder Order";
|
||||
|
||||
return new QuoteItem
|
||||
{
|
||||
QuoteId = quoteId,
|
||||
Description = description,
|
||||
Quantity = 1,
|
||||
IsGenericItem = true,
|
||||
ManualUnitPrice = totalCost,
|
||||
UnitPrice = totalCost,
|
||||
TotalPrice = totalCost,
|
||||
ItemMaterialCost = totalCost,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc,
|
||||
Coats = [],
|
||||
PrepServices = []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
|
||||
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
|
||||
/// new (or existing) inventory record.
|
||||
///
|
||||
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
|
||||
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
|
||||
/// only reflects powders the shop is actually going to process.
|
||||
///
|
||||
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
|
||||
/// same InventoryItemId — no duplicate records are created.
|
||||
///
|
||||
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
|
||||
/// on an already-approved quote (e.g. retry after a transient error) is safe.
|
||||
/// </summary>
|
||||
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
|
||||
{
|
||||
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
|
||||
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
|
||||
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
|
||||
false,
|
||||
qi => qi.Coats);
|
||||
|
||||
var pendingCoats = quoteItems
|
||||
.SelectMany(qi => qi.Coats)
|
||||
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
|
||||
.ToList();
|
||||
|
||||
if (pendingCoats.Count == 0) return;
|
||||
|
||||
// Group by catalog item ID so each unique powder generates exactly one inventory record.
|
||||
var groups = pendingCoats
|
||||
.GroupBy(c => c.PowderCatalogItemId!.Value)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
|
||||
if (newInventoryId == null) continue;
|
||||
|
||||
// Link every coat in this group to the single newly-created inventory record.
|
||||
foreach (var coat in group)
|
||||
{
|
||||
coat.InventoryItemId = newInventoryId;
|
||||
coat.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
||||
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
|
||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
||||
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
|
||||
/// and the Company Settings live preview (so the UI always shows the same rate
|
||||
/// the AI will use — single formula path, no client-side duplication).
|
||||
///
|
||||
/// Formula:
|
||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
||||
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
|
||||
/// determines throughput and CFM draw. CFM is not used in the rate formula.
|
||||
///
|
||||
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
|
||||
/// All multipliers are relative to that baseline.
|
||||
/// Sources:
|
||||
/// Pressure pot rates — averaged from two industry standard abrasive blast
|
||||
/// cleaning reference tables.
|
||||
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
|
||||
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
|
||||
/// </summary>
|
||||
public static class ShopCapabilityCalculator
|
||||
{
|
||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
||||
// ── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr.
|
||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
||||
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
|
||||
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (costs.CompressorCfm <= 0)
|
||||
return 0m;
|
||||
|
||||
var baseRate = BaseByCfm(costs.CompressorCfm);
|
||||
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
|
||||
var setup = SetupMultiplier(costs.BlastSetupType);
|
||||
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
|
||||
|
||||
return Math.Round(baseRate * nozzle * setup * substrate, 1);
|
||||
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
||||
/// otherwise derives from the setup's equipment specs.
|
||||
/// Returns the effective blast rate in sqft/hr for a named blast setup.
|
||||
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||
{
|
||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (setup.CompressorCfm <= 0)
|
||||
return 0m;
|
||||
|
||||
var baseRate = BaseByCfm(setup.CompressorCfm);
|
||||
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
|
||||
var setupMult = SetupMultiplier(setup.SetupType);
|
||||
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
|
||||
|
||||
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
|
||||
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective coating application rate in sqft/hr.
|
||||
/// If override is set, returns it directly.
|
||||
/// Otherwise derives a sensible default from gun type.
|
||||
/// Override bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||
return costs.CoatingRateSqFtPerHourOverride.Value;
|
||||
|
||||
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
|
||||
// Without more equipment data (voltage, gun model) we use a single reasonable default.
|
||||
return costs.CoatingGunType switch
|
||||
{
|
||||
CoatingGunType.Corona => 40m,
|
||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
||||
CoatingGunType.Tribo => 35m,
|
||||
CoatingGunType.Both => 40m,
|
||||
_ => 40m
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default equipment field values for a given capability tier.
|
||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
||||
/// starting values even if they never visit the Quoting Calibration tab.
|
||||
/// Returns default equipment field values for a given capability tier, applied
|
||||
/// during Setup Wizard tier selection so new shops get reasonable starting values.
|
||||
/// CFM defaults reflect typical compressor sizes for each tier; they appear in the
|
||||
/// UI for reference but are not used in the rate formula.
|
||||
/// </summary>
|
||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||
{
|
||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
|
||||
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
|
||||
};
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
// ── Core formula (single path for all callers) ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
||||
/// Calibrated so that real-world examples produce expected results:
|
||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
||||
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
|
||||
/// setup type routes to the appropriate reference table; substrate adjusts for
|
||||
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
|
||||
/// not an independent variable in throughput.
|
||||
/// </summary>
|
||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
||||
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
|
||||
{
|
||||
< 10 => 5m,
|
||||
< 20 => 9m,
|
||||
< 40 => 15m,
|
||||
< 80 => 25m,
|
||||
< 120 => 35m,
|
||||
_ => 45m
|
||||
var baseRate = setupType switch
|
||||
{
|
||||
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
|
||||
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
|
||||
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
|
||||
BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
|
||||
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
|
||||
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
|
||||
_ => 0m
|
||||
};
|
||||
|
||||
return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
|
||||
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
|
||||
/// #1 (1/16"): 20-35 sqft/hr avg → 20
|
||||
/// #2 (1/8"): 40-60 sqft/hr avg → 40
|
||||
/// #3 (3/16"): 60-85 sqft/hr avg → 75
|
||||
/// #4 (1/4"): 90-110 sqft/hr avg → 115
|
||||
/// #5 (5/16"): 130-160 sqft/hr avg → 175
|
||||
/// #6 (3/8"): 180-230 sqft/hr avg → 245
|
||||
/// #7 (7/16"): 240-300 sqft/hr avg → 325
|
||||
/// #8 (1/2"): 320-400 sqft/hr avg → 430
|
||||
/// </summary>
|
||||
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
|
||||
{
|
||||
1 => 20m,
|
||||
2 => 40m,
|
||||
3 => 75m,
|
||||
4 => 115m,
|
||||
5 => 175m,
|
||||
6 => 245m,
|
||||
7 => 325m,
|
||||
8 => 430m,
|
||||
_ => 100m
|
||||
};
|
||||
|
||||
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
|
||||
/// <summary>
|
||||
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
|
||||
/// Source: industry reference table for siphon cabinet production rates.
|
||||
/// #1 (1/16"): 10-25 sqft/hr → 18
|
||||
/// #2 (1/8"): 25-50 sqft/hr → 38
|
||||
/// #3 (3/16"): 50-100 sqft/hr → 75
|
||||
/// #4 (1/4"): 100-150 sqft/hr → 125
|
||||
/// #5 (5/16"): 150-225 sqft/hr → 188
|
||||
/// #6 (3/8"): 225-300 sqft/hr → 263
|
||||
/// #7 (7/16"): 300-375 sqft/hr → 338
|
||||
/// #8 (1/2"): 375-450 sqft/hr → 413
|
||||
/// </summary>
|
||||
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
|
||||
{
|
||||
2 => 0.35m,
|
||||
3 => 0.55m,
|
||||
4 => 0.75m,
|
||||
5 => 1.00m,
|
||||
6 => 1.30m,
|
||||
7 => 1.65m,
|
||||
8 => 2.00m,
|
||||
_ => 1.00m
|
||||
};
|
||||
|
||||
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
|
||||
{
|
||||
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
|
||||
BlastSetupType.SiphonPot => 0.70m,
|
||||
BlastSetupType.PressurePot => 1.00m, // baseline
|
||||
BlastSetupType.WetBlasting => 0.60m,
|
||||
_ => 1.00m
|
||||
1 => 18m,
|
||||
2 => 38m,
|
||||
3 => 75m,
|
||||
4 => 125m,
|
||||
5 => 188m,
|
||||
6 => 263m,
|
||||
7 => 338m,
|
||||
8 => 413m,
|
||||
_ => 80m
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
|
||||
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
|
||||
/// </summary>
|
||||
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
||||
{
|
||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
||||
BlastSubstrateType.PowderCoat => 1.25m,
|
||||
BlastSubstrateType.Paint => 1.00m,
|
||||
BlastSubstrateType.Mixed => 0.90m,
|
||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
||||
BlastSubstrateType.RustAndScale => 0.70m,
|
||||
_ => 0.90m
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
|
||||
// Passkey enrollment prompt
|
||||
public bool PasskeyPromptDismissed { get; set; } = false;
|
||||
|
||||
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
|
||||
public string? KioskPin { get; set; }
|
||||
|
||||
// Ban
|
||||
public bool IsBanned { get; set; } = false;
|
||||
public DateTime? BannedAt { get; set; }
|
||||
|
||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
||||
/// </summary>
|
||||
public int ReminderMinutesBefore { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
|
||||
/// The background service uses this as a deduplication guard to prevent double-sending.
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual Customer? Customer { get; set; }
|
||||
public virtual Job? Job { get; set; }
|
||||
|
||||
@@ -133,6 +133,15 @@ public class Company : BaseEntity
|
||||
/// </summary>
|
||||
public string? KioskActivationToken { get; set; }
|
||||
|
||||
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
|
||||
public bool TimeclockEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
|
||||
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
|
||||
|
||||
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
|
||||
public int? TimeclockAutoClockOutHours { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A per-company reusable pricing formula template. Users define named input fields and an
|
||||
/// NCalc expression that produces either a fixed dollar amount (FixedRate) or a surface area
|
||||
/// in square feet (SurfaceAreaSqFt) that feeds the standard coatings pricing path.
|
||||
/// </summary>
|
||||
public class CustomItemTemplate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — controls which pricing path is used after evaluation.</summary>
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Default rate value populated into the quote wizard; user can override per quote.</summary>
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
/// <summary>Display label for the rate field, e.g. "$/sq in" or "$/lb".</summary>
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference diagram (shop drawing, sketch) stored in blob storage.
|
||||
/// Shown in the template editor and quote wizard so users know which measurements to take.
|
||||
/// Path format: {companyId}/{templateId}/diagram.{ext}
|
||||
/// </summary>
|
||||
public string? DiagramImagePath { get; set; }
|
||||
|
||||
// ── Community library tracking ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Set when this template was imported from the community library.
|
||||
/// Null for originally created templates.
|
||||
/// </summary>
|
||||
public int? SourceFormulaLibraryItemId { get; set; }
|
||||
public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the user edits an imported template. Only modified imports (and original
|
||||
/// creations) are eligible to be shared back to the community library.
|
||||
/// </summary>
|
||||
public bool IsModifiedFromSource { get; set; }
|
||||
}
|
||||
@@ -41,6 +41,17 @@ public class Customer : BaseEntity
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? LastContactDate { get; set; }
|
||||
|
||||
// CRM fields
|
||||
/// <summary>How the customer found the shop (Walk-In, Google Search, Customer Referral, etc.).</summary>
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address (separate from billing address above)
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
// Notification preferences
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
||||
@@ -55,4 +66,5 @@ public class Customer : BaseEntity
|
||||
|
||||
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
||||
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
||||
public virtual ICollection<CustomerContact> CustomerContacts { get; set; } = new List<CustomerContact>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// An additional contact person associated with a customer account.
|
||||
/// Commercial customers frequently have separate billing, operations, and drop-off contacts.
|
||||
/// The primary contact remains on the Customer entity; these are supplementary.
|
||||
/// </summary>
|
||||
public class CustomerContact : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
/// <summary>Job title / role at the company, e.g. "Purchasing Manager".</summary>
|
||||
[StringLength(100)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Functional role: Billing, Operations, Drop-off, Sales, General, etc.</summary>
|
||||
[StringLength(50)]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer? Customer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Facility-level clock-in/clock-out record for an employee.
|
||||
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
|
||||
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
|
||||
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
|
||||
/// </summary>
|
||||
public class EmployeeClockEntry : BaseEntity
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
public DateTime ClockInTime { get; set; }
|
||||
|
||||
/// <summary>Null means the employee is currently clocked in.</summary>
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
|
||||
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
|
||||
public decimal? HoursWorked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this segment is regular work time, a break, or a lunch period.
|
||||
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
|
||||
/// </summary>
|
||||
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual ApplicationUser User { get; set; } = null!;
|
||||
}
|
||||
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
|
||||
public string? Location { get; set; }
|
||||
|
||||
// Maintenance Information
|
||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||
public DateTime? LastMaintenanceDate { get; set; }
|
||||
public DateTime? NextScheduledMaintenance { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Records that a company imported a specific FormulaLibraryItem into their local template library.
|
||||
/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
|
||||
/// same item overwrites the existing row rather than creating a duplicate.
|
||||
/// </summary>
|
||||
public class FormulaLibraryImport : BaseEntity
|
||||
{
|
||||
public int FormulaLibraryItemId { get; set; }
|
||||
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||
|
||||
public string ImportedByUserId { get; set; } = string.Empty;
|
||||
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>The CustomItemTemplate row created in this company's local library on import.</summary>
|
||||
public int ResultingCustomItemTemplateId { get; set; }
|
||||
public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Platform-level community library entry for a shared custom formula template.
|
||||
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
|
||||
/// Shared voluntarily by the originating company; imported as independent copies by others.
|
||||
/// </summary>
|
||||
public class FormulaLibraryItem
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
// ── Formula content (copied from CustomItemTemplate at share time) ─────
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.</summary>
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Blob path referencing the source template's diagram image.
|
||||
/// Nulled out (here and on all imports) if the source template's diagram is removed.
|
||||
/// </summary>
|
||||
public string? DiagramImagePath { get; set; }
|
||||
|
||||
// ── Attribution ────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Comma-separated community tags, e.g. "HVAC,Sheet Metal".</summary>
|
||||
public string? Tags { get; set; }
|
||||
|
||||
/// <summary>Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".</summary>
|
||||
public string? IndustryHint { get; set; }
|
||||
|
||||
/// <summary>Id of the CustomItemTemplate this was shared from.</summary>
|
||||
public int SourceCustomItemTemplateId { get; set; }
|
||||
|
||||
public int SourceCompanyId { get; set; }
|
||||
|
||||
/// <summary>Denormalized company name so it renders without a join when the company is gone.</summary>
|
||||
public string SourceCompanyName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// When non-null, this entry was derived from an imported formula that was subsequently
|
||||
/// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
|
||||
/// </summary>
|
||||
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||
public virtual FormulaLibraryItem? InspiredBy { get; set; }
|
||||
|
||||
public string SharedByUserId { get; set; } = string.Empty;
|
||||
public DateTime SharedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>False when the creator has removed it from the community library.</summary>
|
||||
public bool IsPublished { get; set; } = true;
|
||||
|
||||
/// <summary>Running count of how many companies have imported this entry.</summary>
|
||||
public int ImportCount { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// One thumbs-up or thumbs-down vote per company per library formula.
|
||||
/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
|
||||
/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
|
||||
/// </summary>
|
||||
public class FormulaLibraryRating
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
public int FormulaLibraryItemId { get; set; }
|
||||
|
||||
/// <summary>The company casting the vote.</summary>
|
||||
public int CompanyId { get; set; }
|
||||
|
||||
/// <summary>True = thumbs up, false = thumbs down.</summary>
|
||||
public bool IsPositive { get; set; }
|
||||
|
||||
public DateTime RatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||
}
|
||||
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
|
||||
|
||||
// Relationships
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||
}
|
||||
|
||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
|
||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
||||
|
||||
// Additional Information
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||
public string? Tags { get; set; }
|
||||
|
||||
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
|
||||
public int? AiPredictionId { get; set; }
|
||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||
|
||||
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||
|
||||
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
|
||||
// Relationships
|
||||
public virtual Job Job { get; set; } = null!;
|
||||
public virtual CatalogItem? CatalogItem { get; set; }
|
||||
|
||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
||||
public string? PowderReceivedByUserId { get; set; }
|
||||
public decimal? PowderReceivedLbs { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Conversion tracking
|
||||
|
||||
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
|
||||
public int? AiPredictionId { get; set; }
|
||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||
|
||||
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||
|
||||
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
|
||||
// Relationships
|
||||
public virtual Quote Quote { get; set; } = null!;
|
||||
public virtual CatalogItem? CatalogItem { get; set; }
|
||||
|
||||
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
|
||||
|
||||
// Powder selection (same pattern as current QuoteItem)
|
||||
public int? InventoryItemId { get; set; } // In-stock powder
|
||||
/// <summary>
|
||||
/// Platform powder catalog item that this coat was sourced from.
|
||||
/// Persisted so that at quote-approval time the system can create exactly one
|
||||
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
|
||||
/// than creating during quote-save when the job may never be approved.
|
||||
/// </summary>
|
||||
public int? PowderCatalogItemId { get; set; }
|
||||
public string? ColorName { get; set; } // Color name
|
||||
public int? VendorId { get; set; } // Vendor for custom powder
|
||||
public string? ColorCode { get; set; } // RAL code, etc.
|
||||
@@ -33,6 +40,13 @@ public class QuoteItemCoat : BaseEntity
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
|
||||
public bool IsBillableToCustomer { get; set; }
|
||||
public string? BillingNotes { get; set; }
|
||||
|
||||
// Pricing attribution for the linked rework job (null on pre-existing records)
|
||||
public ReworkPricingType? ReworkPricingType { get; set; }
|
||||
|
||||
// ── Resolution ────────────────────────────────────────────────────────────
|
||||
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
||||
public ReworkResolution? Resolution { get; set; }
|
||||
|
||||
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
|
||||
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
||||
public bool AllowSms { get; set; } = false;
|
||||
|
||||
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
|
||||
public bool AllowCustomFormulas { get; set; } = false;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
|
||||
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
||||
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
||||
public virtual Account? DefaultExpenseAccount { get; set; }
|
||||
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
|
||||
}
|
||||
|
||||
public class InventoryTransaction : BaseEntity
|
||||
@@ -151,6 +152,20 @@ public class CustomerNote : BaseEntity
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an inventory item as a preferred powder for a specific customer.
|
||||
/// Shown on Customer Details for faster quoting of repeat orders.
|
||||
/// </summary>
|
||||
public class CustomerPreferredPowder : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public int InventoryItemId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
public virtual InventoryItem InventoryItem { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class JobStatusHistory : BaseEntity
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an activated shop-floor kiosk tablet for the timeclock.
|
||||
/// One row per device; multiple rows per company are supported so shops can have
|
||||
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
|
||||
/// device-specific cookie and validated on every kiosk request.
|
||||
/// </summary>
|
||||
public class TimeclockKioskDevice : BaseEntity
|
||||
{
|
||||
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC timestamp when a manager activated this device.</summary>
|
||||
public DateTime ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -144,6 +144,14 @@ public enum ReworkResolution
|
||||
NoActionRequired = 4
|
||||
}
|
||||
|
||||
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
|
||||
public enum ReworkPricingType
|
||||
{
|
||||
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
|
||||
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
|
||||
CustomerFull = 2 // Customer caused it; full original pricing applies
|
||||
}
|
||||
|
||||
public enum BugReportStatus
|
||||
{
|
||||
New = 0,
|
||||
|
||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
||||
SmsInboundStop = 12,
|
||||
SmsInboundHelp = 13,
|
||||
AdminEmail = 14,
|
||||
SmsInboundStart = 15
|
||||
SmsInboundStart = 15,
|
||||
AppointmentReminder = 17,
|
||||
AppointmentReminderStaff = 18
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace PowderCoating.Core.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
|
||||
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
|
||||
/// </summary>
|
||||
public enum ClockEntryType
|
||||
{
|
||||
/// <summary>Normal productive work time (default).</summary>
|
||||
Work = 0,
|
||||
|
||||
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
|
||||
Break = 1,
|
||||
|
||||
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
|
||||
Lunch = 2
|
||||
}
|
||||
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
|
||||
IJobPhotoRepository JobPhotos { get; }
|
||||
IRepository<JobNote> JobNotes { get; }
|
||||
IRepository<CustomerNote> CustomerNotes { get; }
|
||||
IRepository<CustomerContact> CustomerContacts { get; }
|
||||
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||
IRepository<PricingTier> PricingTiers { get; }
|
||||
|
||||
@@ -155,6 +157,18 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
// Customer Intake Kiosk
|
||||
IRepository<KioskSession> KioskSessions { get; }
|
||||
|
||||
// Custom Formula Templates
|
||||
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
|
||||
|
||||
// Formula Community Library
|
||||
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
|
||||
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
|
||||
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
|
||||
|
||||
// Employee Timeclock
|
||||
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
|
||||
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
|
||||
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||
|
||||
|
||||
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
|
||||
/// were never completed and rolled past their scheduled day.
|
||||
/// </summary>
|
||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||
/// </summary>
|
||||
Task<int> GetReworkJobCountAsync(int originalJobId);
|
||||
}
|
||||
|
||||
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||
return companyId;
|
||||
|
||||
return null;
|
||||
// Authenticated but CompanyId claim is missing or invalid.
|
||||
// Return 0 (never a real company ID) so the global filter generates
|
||||
// "CompanyId = 0" which matches nothing — prevents null-comparison
|
||||
// ambiguity from leaking cross-tenant rows.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
{
|
||||
get
|
||||
{
|
||||
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
|
||||
if (_httpContextAccessor?.HttpContext == null) return true;
|
||||
if (!IsSuperAdmin) return false;
|
||||
return CurrentCompanyId == null || CurrentCompanyId == 1;
|
||||
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
|
||||
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<JobNote> JobNotes { get; set; }
|
||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||||
/// <summary>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerContact> CustomerContacts { get; set; }
|
||||
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
|
||||
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
|
||||
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||||
@@ -282,6 +293,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// </summary>
|
||||
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
||||
|
||||
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
|
||||
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
|
||||
|
||||
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
|
||||
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
|
||||
|
||||
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
|
||||
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
|
||||
|
||||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BugReport> BugReports { get; set; }
|
||||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||||
@@ -367,6 +387,17 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||
|
||||
// Custom Formula Templates
|
||||
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
|
||||
|
||||
// Employee Timeclock
|
||||
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
|
||||
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
|
||||
|
||||
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
|
||||
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||
/// No global query filter — SuperAdmin controllers query this directly.
|
||||
@@ -524,6 +555,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
@@ -760,6 +793,32 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
.HasForeignKey(k => k.LinkedJobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Custom Formula Templates — tenant-filtered + soft delete
|
||||
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Employee Timeclock — tenant-filtered + soft delete
|
||||
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
|
||||
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
|
||||
modelBuilder.Entity<EmployeeClockEntry>()
|
||||
.HasOne(c => c.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
// Composite index for "who's clocked in today" and date-range attendance reports
|
||||
modelBuilder.Entity<EmployeeClockEntry>()
|
||||
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
|
||||
|
||||
// Timeclock kiosk devices — one row per activated tablet per company
|
||||
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
|
||||
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||
.HasIndex(d => d.Token).IsUnique();
|
||||
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||
.HasIndex(d => d.CompanyId);
|
||||
|
||||
// Account self-referencing hierarchy
|
||||
modelBuilder.Entity<Account>()
|
||||
.HasOne(a => a.ParentAccount)
|
||||
@@ -802,6 +861,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
|
||||
modelBuilder.Entity<Vendor>()
|
||||
.HasMany(v => v.Categories)
|
||||
.WithMany(c => c.Vendors)
|
||||
.UsingEntity<Dictionary<string, object>>(
|
||||
"VendorInventoryCategories",
|
||||
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
|
||||
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
|
||||
|
||||
// Bill → APAccount (no cascade to avoid cycles)
|
||||
modelBuilder.Entity<Bill>()
|
||||
.HasOne(b => b.APAccount)
|
||||
@@ -1657,6 +1725,23 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||||
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.Customer)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.CustomerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.InventoryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.InventoryItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// ===================================================================
|
||||
// END PERFORMANCE OPTIMIZATION INDEXES
|
||||
// ===================================================================
|
||||
@@ -2021,6 +2106,61 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
||||
|
||||
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
|
||||
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasOne(f => f.InspiredBy)
|
||||
.WithMany()
|
||||
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasIndex(f => f.SourceCompanyId)
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasIndex(f => f.IsPublished)
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||
|
||||
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasOne(i => i.FormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FormulaLibraryItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasOne(i => i.ResultingCustomItemTemplate)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||
|
||||
// FormulaLibraryRating — platform-level; one vote per company per formula
|
||||
modelBuilder.Entity<FormulaLibraryRating>()
|
||||
.HasOne(r => r.FormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FormulaLibraryItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryRating>()
|
||||
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||
|
||||
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
|
||||
modelBuilder.Entity<CustomItemTemplate>()
|
||||
.HasOne(t => t.SourceFormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+10633
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
|
||||
// These UpdateData calls were generated from an existing live database.
|
||||
// On a fresh install the PricingTiers table and its seed rows may not exist yet
|
||||
// (seeding is manual via Platform Management → Seed Data), so guard each update.
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
|
||||
END
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
Generated
+10633
File diff suppressed because it is too large
Load Diff
+217
@@ -0,0 +1,217 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAppointmentReminderSentAt : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Use IF EXISTS guards for all ShopWorker drops — prod and dev diverged on whether
|
||||
// these objects exist, so unconditional drops would fail on whichever DB is missing them.
|
||||
migrationBuilder.Sql(@"
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_Jobs_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [Jobs] DROP CONSTRAINT [FK_Jobs_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_JobTimeEntries_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [JobTimeEntries] DROP CONSTRAINT [FK_JobTimeEntries_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_MaintenanceRecords_ShopWorkers_ShopWorkerId')
|
||||
ALTER TABLE [MaintenanceRecords] DROP CONSTRAINT [FK_MaintenanceRecords_ShopWorkers_ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkerRoleCosts')
|
||||
DROP TABLE [ShopWorkerRoleCosts];
|
||||
IF EXISTS (SELECT 1 FROM sys.tables WHERE name = 'ShopWorkers')
|
||||
DROP TABLE [ShopWorkers];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_MaintenanceRecords_ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
|
||||
DROP INDEX [IX_MaintenanceRecords_ShopWorkerId] ON [MaintenanceRecords];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_JobTimeEntries_ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
|
||||
DROP INDEX [IX_JobTimeEntries_ShopWorkerId] ON [JobTimeEntries];
|
||||
IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'IX_Jobs_ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
|
||||
DROP INDEX [IX_Jobs_ShopWorkerId] ON [Jobs];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('MaintenanceRecords'))
|
||||
ALTER TABLE [MaintenanceRecords] DROP COLUMN [ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('JobTimeEntries'))
|
||||
ALTER TABLE [JobTimeEntries] DROP COLUMN [ShopWorkerId];
|
||||
IF EXISTS (SELECT 1 FROM sys.columns WHERE name = 'ShopWorkerId' AND object_id = OBJECT_ID('Jobs'))
|
||||
ALTER TABLE [Jobs] DROP COLUMN [ShopWorkerId];
|
||||
");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ReminderSentAt",
|
||||
table: "Appointments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2970));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2976));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 12, 57, 355, DateTimeKind.Utc).AddTicks(2977));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReminderSentAt",
|
||||
table: "Appointments");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ShopWorkerId",
|
||||
table: "Jobs",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShopWorkerRoleCosts",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
HourlyRate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
Role = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShopWorkerRoleCosts", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "ShopWorkers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Email = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Phone = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Role = table.Column<int>(type: "int", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_ShopWorkers", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_ShopWorkers_Companies_CompanyId",
|
||||
column: x => x.CompanyId,
|
||||
principalTable: "Companies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MaintenanceRecords_ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobTimeEntries_ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Jobs_ShopWorkerId",
|
||||
table: "Jobs",
|
||||
column: "ShopWorkerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShopWorkerRoleCosts_CompanyId_Role",
|
||||
table: "ShopWorkerRoleCosts",
|
||||
columns: new[] { "CompanyId", "Role" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShopWorkers_CompanyId",
|
||||
table: "ShopWorkers",
|
||||
column: "CompanyId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Jobs_ShopWorkers_ShopWorkerId",
|
||||
table: "Jobs",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobTimeEntries_ShopWorkers_ShopWorkerId",
|
||||
table: "JobTimeEntries",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_MaintenanceRecords_ShopWorkers_ShopWorkerId",
|
||||
table: "MaintenanceRecords",
|
||||
column: "ShopWorkerId",
|
||||
principalTable: "ShopWorkers",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10639
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 AddNoExtraLayerChargeToCoats : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "QuoteItemCoats",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "JobItemCoats",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "QuoteItemCoats");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "NoExtraLayerCharge",
|
||||
table: "JobItemCoats");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3960));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3966));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 15, 44, 18, 742, DateTimeKind.Utc).AddTicks(3967));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10642
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddReworkPricingType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "ReworkPricingType",
|
||||
table: "ReworkRecords",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ReworkPricingType",
|
||||
table: "ReworkRecords");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorCategories : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorInventoryCategories",
|
||||
columns: table => new
|
||||
{
|
||||
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
|
||||
column: x => x.InventoryCategoryLookupId,
|
||||
principalTable: "InventoryCategoryLookups",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorInventoryCategories_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorInventoryCategories_VendorId",
|
||||
table: "VendorInventoryCategories",
|
||||
column: "VendorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorInventoryCategories");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10780
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomItemTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "QuoteItems",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "QuoteItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "QuoteItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "JobItems",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "JobItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "JobItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CustomItemTemplates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DisplayOrder = table.Column<int>(type: "int", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CustomItemTemplates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||
table: "QuoteItems",
|
||||
column: "CustomItemTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobItems_CustomItemTemplateId",
|
||||
table: "JobItems",
|
||||
column: "CustomItemTemplateId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "JobItems",
|
||||
column: "CustomItemTemplateId",
|
||||
principalTable: "CustomItemTemplates",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "QuoteItems",
|
||||
column: "CustomItemTemplateId",
|
||||
principalTable: "CustomItemTemplates",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CustomItemTemplates");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_JobItems_CustomItemTemplateId",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10672
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeMaintenanceIntervalNullable : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "RecommendedMaintenanceIntervalDays",
|
||||
table: "Equipment",
|
||||
type: "int",
|
||||
nullable: true,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AlterColumn<int>(
|
||||
name: "RecommendedMaintenanceIntervalDays",
|
||||
table: "Equipment",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0,
|
||||
oldClrType: typeof(int),
|
||||
oldType: "int",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAllowCustomFormulas : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AllowCustomFormulas",
|
||||
table: "SubscriptionPlanConfigs",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AllowCustomFormulas",
|
||||
table: "SubscriptionPlanConfigs");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10854
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddEmployeeTimeclock : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "KioskPin",
|
||||
table: "AspNetUsers",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmployeeClockEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ClockInTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
ClockOutTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
HoursWorked = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmployeeClockEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_EmployeeClockEntries_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeClockEntries_CompanyId_ClockInTime",
|
||||
table: "EmployeeClockEntries",
|
||||
columns: new[] { "CompanyId", "ClockInTime" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_EmployeeClockEntries_UserId",
|
||||
table: "EmployeeClockEntries",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "EmployeeClockEntries");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "KioskPin",
|
||||
table: "AspNetUsers");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10857
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTimeclockKioskToken : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TimeclockKioskToken",
|
||||
table: "Companies",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TimeclockKioskToken",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTimeclockSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TimeclockKioskToken",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "TimeclockAutoClockOutHours",
|
||||
table: "Companies",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "TimeclockEnabled",
|
||||
table: "Companies",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TimeclockKioskDevices",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
DeviceName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Token = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TimeclockKioskDevices", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TimeclockKioskDevices_CompanyId",
|
||||
table: "TimeclockKioskDevices",
|
||||
column: "CompanyId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_TimeclockKioskDevices_Token",
|
||||
table: "TimeclockKioskDevices",
|
||||
column: "Token",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TimeclockKioskDevices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TimeclockAutoClockOutHours",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "TimeclockEnabled",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "TimeclockKioskToken",
|
||||
table: "Companies",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||
}
|
||||
}
|
||||
}
|
||||
+10921
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddClockEntryType : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EntryType",
|
||||
table: "EmployeeClockEntries",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EntryType",
|
||||
table: "EmployeeClockEntries");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10924
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPowderCatalogItemIdToCoat : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PowderCatalogItemId",
|
||||
table: "QuoteItemCoats",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PowderCatalogItemId",
|
||||
table: "QuoteItemCoats");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||
}
|
||||
}
|
||||
}
|
||||
+11119
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user