Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a6f855c05 | |||
| d28e639d1b | |||
| 19b7a9a473 | |||
| 4650ba3d4d | |||
| 1eba50cf0f | |||
| e443457139 | |||
| edf56c1164 | |||
| b9cd693421 | |||
| d77b3778ac | |||
| a7bf97a2df | |||
| 05935b110a | |||
| 64a9c1531b | |||
| f018653c18 | |||
| 15b070398b | |||
| 14f220347b | |||
| baec0b33f7 | |||
| dfb1d34af3 | |||
| 8c86eba4f2 | |||
| d4dddfa727 | |||
| 1bb07162cd | |||
| ec925f9e08 | |||
| 600196f679 | |||
| eb13283e76 | |||
| 30c644a8ec | |||
| 0e480adbf6 | |||
| eaab0af51f | |||
| 51a5268bc2 | |||
| a0bdd2b5b4 | |||
| 21b39161a3 | |||
| b241daf15e | |||
| 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 |
@@ -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
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
## files generated by popular Visual Studio add-ons.
|
## 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
|
# User-specific files
|
||||||
*.rsuser
|
*.rsuser
|
||||||
*.suo
|
*.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,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; }
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ public class EquipmentDto
|
|||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
public int? DaysUntilMaintenance { get; set; }
|
public int? DaysUntilMaintenance { get; set; }
|
||||||
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
|
|||||||
|
|
||||||
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
||||||
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Last Maintenance Date")]
|
[Display(Name = "Last Maintenance Date")]
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ public class InventoryListDto
|
|||||||
public string? CategoryName { get; set; }
|
public string? CategoryName { get; set; }
|
||||||
public string Category { get; set; } = string.Empty; // Legacy field
|
public string Category { get; set; } = string.Empty; // Legacy field
|
||||||
public string? ColorName { get; set; }
|
public string? ColorName { get; set; }
|
||||||
|
public string? Location { get; set; }
|
||||||
public decimal QuantityOnHand { get; set; }
|
public decimal QuantityOnHand { get; set; }
|
||||||
public string UnitOfMeasure { get; set; } = "lbs";
|
public string UnitOfMeasure { get; set; } = "lbs";
|
||||||
public decimal ReorderPoint { get; set; }
|
public decimal ReorderPoint { get; set; }
|
||||||
|
|||||||
@@ -33,6 +33,10 @@ public class InvoiceDto
|
|||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public string? CustomerPhone { get; set; }
|
public string? CustomerPhone { get; set; }
|
||||||
public string? CustomerMobilePhone { 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 CustomerNotifyByEmail { get; set; }
|
||||||
public bool CustomerNotifyBySms { get; set; }
|
public bool CustomerNotifyBySms { get; set; }
|
||||||
public string? PreparedById { get; set; }
|
public string? PreparedById { get; set; }
|
||||||
|
|||||||
@@ -137,6 +137,13 @@ public class CreateJobDto
|
|||||||
[Display(Name = "Oven")]
|
[Display(Name = "Oven")]
|
||||||
public int? OvenCostId { get; set; }
|
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")]
|
[Required(ErrorMessage = "Description is required")]
|
||||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Description")]
|
[Display(Name = "Description")]
|
||||||
@@ -208,6 +215,16 @@ public class UpdateJobDto
|
|||||||
[Display(Name = "Assigned Worker")]
|
[Display(Name = "Assigned Worker")]
|
||||||
public string? AssignedUserId { get; set; }
|
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")]
|
[Required(ErrorMessage = "Description is required")]
|
||||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Description")]
|
[Display(Name = "Description")]
|
||||||
@@ -308,7 +325,11 @@ public class JobItemDto
|
|||||||
public bool IsGenericItem { get; set; }
|
public bool IsGenericItem { get; set; }
|
||||||
public bool IsLaborItem { get; set; }
|
public bool IsLaborItem { get; set; }
|
||||||
public bool IsSalesItem { get; set; }
|
public bool IsSalesItem { get; set; }
|
||||||
|
public bool IsAiItem { get; set; }
|
||||||
public string? Sku { get; set; }
|
public string? Sku { get; set; }
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
public List<JobItemCoatDto> Coats { get; set; } = new();
|
public List<JobItemCoatDto> Coats { get; set; } = new();
|
||||||
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
||||||
}
|
}
|
||||||
@@ -381,6 +402,7 @@ public class JobItemCoatDto
|
|||||||
public decimal? PowderCostPerLb { get; set; }
|
public decimal? PowderCostPerLb { get; set; }
|
||||||
public decimal? PowderToOrder { get; set; }
|
public decimal? PowderToOrder { get; set; }
|
||||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||||
|
public bool NoExtraLayerCharge { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,6 +490,7 @@ public class ReworkRecordDto
|
|||||||
public decimal ActualReworkCost { get; set; }
|
public decimal ActualReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
@@ -493,6 +516,11 @@ public class CreateReworkRecordDto
|
|||||||
public decimal EstimatedReworkCost { get; set; }
|
public decimal EstimatedReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Rework job creation (opt-in)
|
||||||
|
public bool CreateReworkJob { get; set; }
|
||||||
|
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateReworkRecordDto
|
public class UpdateReworkRecordDto
|
||||||
|
|||||||
@@ -475,6 +475,11 @@ public class QuoteItemDto
|
|||||||
|
|
||||||
public bool IsAiItem { get; set; }
|
public bool IsAiItem { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Cost breakdown snapshot
|
// Cost breakdown snapshot
|
||||||
public decimal ItemMaterialCost { get; set; }
|
public decimal ItemMaterialCost { get; set; }
|
||||||
public decimal ItemLaborCost { get; set; }
|
public decimal ItemLaborCost { get; set; }
|
||||||
@@ -559,6 +564,11 @@ public class CreateQuoteItemDto
|
|||||||
|
|
||||||
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -801,6 +811,7 @@ public class QuoteItemCoatDto
|
|||||||
public decimal CoatMaterialCost { get; set; }
|
public decimal CoatMaterialCost { get; set; }
|
||||||
public decimal CoatLaborCost { get; set; }
|
public decimal CoatLaborCost { get; set; }
|
||||||
public decimal CoatTotalCost { get; set; }
|
public decimal CoatTotalCost { get; set; }
|
||||||
|
public bool NoExtraLayerCharge { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ public class CreateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -209,4 +211,6 @@ public class UpdateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -91,4 +91,11 @@ public interface INotificationService
|
|||||||
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
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,
|
CompanyInfoDto companyInfo,
|
||||||
QuoteTemplateSettingsDto? template = null);
|
QuoteTemplateSettingsDto? template = null);
|
||||||
|
|
||||||
|
Task<byte[]> GeneratePackingSlipPdfAsync(
|
||||||
|
InvoiceDto invoiceDto,
|
||||||
|
byte[]? companyLogo,
|
||||||
|
string? companyLogoContentType,
|
||||||
|
CompanyInfoDto companyInfo);
|
||||||
|
|
||||||
Task<byte[]> GeneratePurchaseOrderPdfAsync(
|
Task<byte[]> GeneratePurchaseOrderPdfAsync(
|
||||||
PurchaseOrderDto po,
|
PurchaseOrderDto po,
|
||||||
byte[]? companyLogo,
|
byte[]? companyLogo,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId);
|
int invoiceId);
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,10 @@ public class InvoiceProfile : Profile
|
|||||||
: null))
|
: null))
|
||||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : 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.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.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.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ public class JobProfile : Profile
|
|||||||
.ForMember(dest => dest.JobItemDescription,
|
.ForMember(dest => dest.JobItemDescription,
|
||||||
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
||||||
.ForMember(dest => dest.ReworkJobNumber,
|
.ForMember(dest => dest.ReworkJobNumber,
|
||||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
|
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
|
||||||
|
.ForMember(dest => dest.ReworkPricingType,
|
||||||
|
opt => opt.MapFrom(src => src.ReworkPricingType));
|
||||||
|
|
||||||
// Job → JobDto (rework fields)
|
// Job → JobDto (rework fields)
|
||||||
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
|
|||||||
.ReverseMap()
|
.ReverseMap()
|
||||||
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||||
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
|
|||||||
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
||||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
|
||||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||||
|
|
||||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,26 @@ using PowderCoating.Core.Entities;
|
|||||||
|
|
||||||
namespace PowderCoating.Application.Services;
|
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
|
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)
|
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -35,13 +53,21 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
createdAtUtc);
|
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)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -62,7 +88,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -70,6 +97,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -85,6 +117,13 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -121,13 +160,22 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
createdAtUtc);
|
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)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -151,7 +199,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -160,6 +209,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -175,6 +227,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -207,13 +265,21 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
createdAtUtc);
|
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)
|
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -234,7 +300,8 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
Notes = c.Notes
|
Notes = c.Notes,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
},
|
},
|
||||||
jobItemId,
|
jobItemId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -242,6 +309,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(source);
|
ArgumentNullException.ThrowIfNull(source);
|
||||||
@@ -257,6 +327,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
createdAtUtc);
|
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)
|
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new JobItem
|
return new JobItem
|
||||||
@@ -288,11 +362,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
Complexity = seed.Complexity,
|
Complexity = seed.Complexity,
|
||||||
AiTags = seed.AiTags,
|
AiTags = seed.AiTags,
|
||||||
AiPredictionId = seed.AiPredictionId,
|
AiPredictionId = seed.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = seed.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = seed.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
|
||||||
|
/// </summary>
|
||||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new JobItemCoat
|
return new JobItemCoat
|
||||||
@@ -310,11 +390,17 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
PowderCostPerLb = seed.PowderCostPerLb,
|
PowderCostPerLb = seed.PowderCostPerLb,
|
||||||
PowderToOrder = seed.PowderToOrder,
|
PowderToOrder = seed.PowderToOrder,
|
||||||
Notes = seed.Notes,
|
Notes = seed.Notes,
|
||||||
|
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
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)
|
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return seeds?
|
return seeds?
|
||||||
@@ -330,6 +416,18 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
.ToList() ?? [];
|
.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)
|
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||||
{
|
{
|
||||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||||
@@ -343,6 +441,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
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(
|
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||||
string? colorName,
|
string? colorName,
|
||||||
string? colorCode,
|
string? colorCode,
|
||||||
@@ -355,6 +459,11 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
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
|
private sealed class JobItemSeed
|
||||||
{
|
{
|
||||||
public string Description { get; init; } = string.Empty;
|
public string Description { get; init; } = string.Empty;
|
||||||
@@ -383,8 +492,12 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public string? Complexity { get; init; }
|
public string? Complexity { get; init; }
|
||||||
public string? AiTags { get; init; }
|
public string? AiTags { get; init; }
|
||||||
public int? AiPredictionId { get; init; }
|
public int? AiPredictionId { get; init; }
|
||||||
|
public bool IsCustomFormulaItem { get; init; }
|
||||||
|
public int? CustomItemTemplateId { get; init; }
|
||||||
|
public string? FormulaFieldValuesJson { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||||
private sealed class JobItemCoatSeed
|
private sealed class JobItemCoatSeed
|
||||||
{
|
{
|
||||||
public string CoatName { get; init; } = string.Empty;
|
public string CoatName { get; init; } = string.Empty;
|
||||||
@@ -399,8 +512,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public decimal? PowderCostPerLb { get; init; }
|
public decimal? PowderCostPerLb { get; init; }
|
||||||
public decimal? PowderToOrder { get; init; }
|
public decimal? PowderToOrder { get; init; }
|
||||||
public string? Notes { 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
|
private sealed class JobItemPrepServiceSeed
|
||||||
{
|
{
|
||||||
public int PrepServiceId { get; init; }
|
public int PrepServiceId { get; init; }
|
||||||
|
|||||||
@@ -2753,4 +2753,187 @@ public class PdfService : IPdfService
|
|||||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
.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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -288,6 +288,24 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
|
||||||
|
// and stored the result as ManualUnitPrice. Use it directly — no coating math.
|
||||||
|
// 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 total = item.ManualUnitPrice.Value * item.Quantity;
|
||||||
|
return new QuoteItemPricingResult
|
||||||
|
{
|
||||||
|
MaterialCost = 0,
|
||||||
|
LaborCost = 0,
|
||||||
|
EquipmentCost = 0,
|
||||||
|
ItemSubtotal = total,
|
||||||
|
UnitPrice = item.ManualUnitPrice.Value,
|
||||||
|
TotalPrice = total
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
||||||
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,20 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace PowderCoating.Application.Services;
|
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
|
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
@@ -25,6 +39,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
_logger = logger;
|
_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)
|
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(quote);
|
ArgumentNullException.ThrowIfNull(quote);
|
||||||
@@ -56,6 +75,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
quote.Total = pricingResult.Total;
|
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(
|
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||||
int quoteId,
|
int quoteId,
|
||||||
@@ -80,6 +105,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return items;
|
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)
|
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||||
{
|
{
|
||||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||||
@@ -98,6 +130,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
|
||||||
|
{
|
||||||
|
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||||
|
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||||
|
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (itemDto.CatalogItemId.HasValue)
|
if (itemDto.CatalogItemId.HasValue)
|
||||||
{
|
{
|
||||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||||
@@ -127,6 +167,12 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
ApplyCalculatedPricing(item, pricing);
|
ApplyCalculatedPricing(item, pricing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||||
|
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
||||||
|
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
||||||
|
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
||||||
|
/// </summary>
|
||||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||||
@@ -158,6 +204,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return coats;
|
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)
|
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||||
@@ -175,6 +222,11 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
.ToList();
|
.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)
|
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new QuoteItem
|
return new QuoteItem
|
||||||
@@ -199,11 +251,15 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
IsAiItem = itemDto.IsAiItem,
|
IsAiItem = itemDto.IsAiItem,
|
||||||
AiTags = itemDto.AiTags,
|
AiTags = itemDto.AiTags,
|
||||||
AiPredictionId = itemDto.AiPredictionId,
|
AiPredictionId = itemDto.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = itemDto.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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)
|
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
return new QuoteItemCoat
|
return new QuoteItemCoat
|
||||||
@@ -219,12 +275,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
TransferEfficiency = coatDto.TransferEfficiency,
|
TransferEfficiency = coatDto.TransferEfficiency,
|
||||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||||
PowderToOrder = coatDto.PowderToOrder,
|
PowderToOrder = coatDto.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
||||||
Notes = coatDto.Notes,
|
Notes = coatDto.Notes,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
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)
|
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||||
{
|
{
|
||||||
item.UnitPrice = pricing.UnitPrice;
|
item.UnitPrice = pricing.UnitPrice;
|
||||||
@@ -234,6 +295,13 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
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)
|
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||||
{
|
{
|
||||||
if (!itemDto.AiPredictionId.HasValue) return;
|
if (!itemDto.AiPredictionId.HasValue) return;
|
||||||
@@ -247,6 +315,23 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
prediction.UpdatedAt = DateTime.UtcNow;
|
prediction.UpdatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
||||||
|
/// platform catalog that doesn't yet exist in their company's inventory.
|
||||||
|
///
|
||||||
|
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
||||||
|
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
||||||
|
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
||||||
|
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
||||||
|
///
|
||||||
|
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
||||||
|
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
||||||
|
/// if it fails, the item is still created with whatever data the catalog has.
|
||||||
|
///
|
||||||
|
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
|
||||||
|
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
|
||||||
|
/// inventory unit cost rather than the now-stale manual price from the quote form.
|
||||||
|
/// </summary>
|
||||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReminderMinutesBefore { get; set; } = 30;
|
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
|
// Navigation Properties
|
||||||
public virtual Customer? Customer { get; set; }
|
public virtual Customer? Customer { get; set; }
|
||||||
public virtual Job? Job { get; set; }
|
public virtual Job? Job { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
|
|||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
// Maintenance Information
|
// Maintenance Information
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
|
|||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
|
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
|
|||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual Job Job { get; set; } = null!;
|
public virtual Job Job { get; set; } = null!;
|
||||||
public virtual CatalogItem? CatalogItem { get; set; }
|
public virtual CatalogItem? CatalogItem { get; set; }
|
||||||
|
|||||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
|||||||
public string? PowderReceivedByUserId { get; set; }
|
public string? PowderReceivedByUserId { get; set; }
|
||||||
public decimal? PowderReceivedLbs { 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
|
// Notes
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
|
|||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual Quote Quote { get; set; } = null!;
|
public virtual Quote Quote { get; set; } = null!;
|
||||||
public virtual CatalogItem? CatalogItem { get; set; }
|
public virtual CatalogItem? CatalogItem { get; set; }
|
||||||
|
|||||||
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
|
|||||||
public decimal CoatLaborCost { get; set; }
|
public decimal CoatLaborCost { get; set; }
|
||||||
public decimal CoatTotalCost { 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
|
// Notes
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
|
|||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Pricing attribution for the linked rework job (null on pre-existing records)
|
||||||
|
public ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
// ── Resolution ────────────────────────────────────────────────────────────
|
// ── Resolution ────────────────────────────────────────────────────────────
|
||||||
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
||||||
public ReworkResolution? Resolution { get; set; }
|
public ReworkResolution? Resolution { get; set; }
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
|
|||||||
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
||||||
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
||||||
public virtual Account? DefaultExpenseAccount { get; set; }
|
public virtual Account? DefaultExpenseAccount { get; set; }
|
||||||
|
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InventoryTransaction : BaseEntity
|
public class InventoryTransaction : BaseEntity
|
||||||
|
|||||||
@@ -144,6 +144,14 @@ public enum ReworkResolution
|
|||||||
NoActionRequired = 4
|
NoActionRequired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
|
||||||
|
public enum ReworkPricingType
|
||||||
|
{
|
||||||
|
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
|
||||||
|
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
|
||||||
|
CustomerFull = 2 // Customer caused it; full original pricing applies
|
||||||
|
}
|
||||||
|
|
||||||
public enum BugReportStatus
|
public enum BugReportStatus
|
||||||
{
|
{
|
||||||
New = 0,
|
New = 0,
|
||||||
|
|||||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
|||||||
SmsInboundStop = 12,
|
SmsInboundStop = 12,
|
||||||
SmsInboundHelp = 13,
|
SmsInboundHelp = 13,
|
||||||
AdminEmail = 14,
|
AdminEmail = 14,
|
||||||
SmsInboundStart = 15
|
SmsInboundStart = 15,
|
||||||
|
AppointmentReminder = 17,
|
||||||
|
AppointmentReminderStaff = 18
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,9 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
|||||||
// Customer Intake Kiosk
|
// Customer Intake Kiosk
|
||||||
IRepository<KioskSession> KioskSessions { get; }
|
IRepository<KioskSession> KioskSessions { get; }
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync();
|
Task<int> SaveChangesAsync();
|
||||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||||
|
|
||||||
|
|||||||
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// were never completed and rolled past their scheduled day.
|
/// were never completed and rolled past their scheduled day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||||
|
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetReworkJobCountAsync(int originalJobId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||||
return 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
|
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;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -367,6 +374,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<KioskSession> KioskSessions { get; set; }
|
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||||
/// No global query filter — SuperAdmin controllers query this directly.
|
/// No global query filter — SuperAdmin controllers query this directly.
|
||||||
@@ -802,6 +813,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
|
||||||
|
modelBuilder.Entity<Vendor>()
|
||||||
|
.HasMany(v => v.Categories)
|
||||||
|
.WithMany(c => c.Vendors)
|
||||||
|
.UsingEntity<Dictionary<string, object>>(
|
||||||
|
"VendorInventoryCategories",
|
||||||
|
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
|
||||||
|
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
|
||||||
|
|
||||||
// Bill → APAccount (no cascade to avoid cycles)
|
// Bill → APAccount (no cascade to avoid cycles)
|
||||||
modelBuilder.Entity<Bill>()
|
modelBuilder.Entity<Bill>()
|
||||||
.HasOne(b => b.APAccount)
|
.HasOne(b => b.APAccount)
|
||||||
|
|||||||
+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 />
|
/// <inheritdoc />
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
{
|
{
|
||||||
migrationBuilder.UpdateData(
|
// These UpdateData calls were generated from an existing live database.
|
||||||
table: "PricingTiers",
|
// On a fresh install the PricingTiers table and its seed rows may not exist yet
|
||||||
keyColumn: "Id",
|
// (seeding is manual via Platform Management → Seed Data), so guard each update.
|
||||||
keyValue: 1,
|
migrationBuilder.Sql(@"
|
||||||
column: "CreatedAt",
|
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
|
BEGIN
|
||||||
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
|
||||||
migrationBuilder.UpdateData(
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
|
||||||
table: "PricingTiers",
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
|
||||||
keyColumn: "Id",
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
|
||||||
keyValue: 2,
|
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
|
||||||
column: "CreatedAt",
|
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
|
END
|
||||||
|
");
|
||||||
migrationBuilder.UpdateData(
|
|
||||||
table: "PricingTiers",
|
|
||||||
keyColumn: "Id",
|
|
||||||
keyValue: 3,
|
|
||||||
column: "CreatedAt",
|
|
||||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
-158
@@ -716,6 +716,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("ReminderMinutesBefore")
|
b.Property<int>("ReminderMinutesBefore")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReminderSentAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<DateTime>("ScheduledEndTime")
|
b.Property<DateTime>("ScheduledEndTime")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -2647,6 +2650,80 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("CreditMemoApplications");
|
b.ToTable("CreditMemoApplications");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("DefaultRate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DiagramImagePath")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("FieldsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Formula")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("OutputMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("RateLabel")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("CustomItemTemplates");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3042,7 +3119,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("PurchasePrice")
|
b.Property<decimal>("PurchasePrice")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("RecommendedMaintenanceIntervalDays")
|
b.Property<int?>("RecommendedMaintenanceIntervalDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("SerialNumber")
|
b.Property<string>("SerialNumber")
|
||||||
@@ -4252,9 +4329,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("ShopSuppliesPercent")
|
b.Property<decimal>("ShopSuppliesPercent")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<string>("SpecialInstructions")
|
b.Property<string>("SpecialInstructions")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -4296,8 +4370,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "CustomerId")
|
b.HasIndex("CompanyId", "CustomerId")
|
||||||
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
||||||
|
|
||||||
@@ -4475,6 +4547,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("CustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -4491,12 +4566,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Finish")
|
b.Property<string>("Finish")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("FormulaFieldValuesJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsAiItem")
|
b.Property<bool>("IsAiItem")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCustomFormulaItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -4560,6 +4641,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CatalogItemId");
|
b.HasIndex("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasIndex("JobId")
|
b.HasIndex("JobId")
|
||||||
.HasDatabaseName("IX_JobItems_JobId");
|
.HasDatabaseName("IX_JobItems_JobId");
|
||||||
|
|
||||||
@@ -4620,6 +4703,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("JobItemId")
|
b.Property<int>("JobItemId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("NoExtraLayerCharge")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -5439,9 +5525,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<string>("Stage")
|
b.Property<string>("Stage")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -5464,8 +5547,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("JobId");
|
b.HasIndex("JobId");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.ToTable("JobTimeEntries");
|
b.ToTable("JobTimeEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -5789,9 +5870,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime>("ScheduledDate")
|
b.Property<DateTime>("ScheduledDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
b.Property<int?>("ShopWorkerId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<int>("Status")
|
b.Property<int>("Status")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -5822,8 +5900,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("ScheduledDate");
|
b.HasIndex("ScheduledDate");
|
||||||
|
|
||||||
b.HasIndex("ShopWorkerId");
|
|
||||||
|
|
||||||
b.HasIndex("Status");
|
b.HasIndex("Status");
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "ScheduledDate")
|
b.HasIndex("CompanyId", "ScheduledDate")
|
||||||
@@ -6720,7 +6796,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3131),
|
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6731,7 +6807,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3137),
|
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6742,7 +6818,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 15, 23, 44, 10, 471, DateTimeKind.Utc).AddTicks(3138),
|
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7269,6 +7345,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("CustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -7282,12 +7361,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("EstimatedMinutes")
|
b.Property<int>("EstimatedMinutes")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("FormulaFieldValuesJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsAiItem")
|
b.Property<bool>("IsAiItem")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCustomFormulaItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -7357,6 +7442,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CatalogItemId");
|
b.HasIndex("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasIndex("QuoteId")
|
b.HasIndex("QuoteId")
|
||||||
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
||||||
|
|
||||||
@@ -7417,6 +7504,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("NoExtraLayerCharge")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -7996,6 +8086,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("ReworkJobId")
|
b.Property<int?>("ReworkJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("ReworkPricingType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("ReworkType")
|
b.Property<int>("ReworkType")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -8019,111 +8112,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("ReworkRecords");
|
b.ToTable("ReworkRecords");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<int>("CompanyId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("CreatedBy")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("DeletedBy")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<string>("Email")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("bit");
|
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
|
||||||
.HasColumnType("bit");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<string>("Phone")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<int>("Role")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("UpdatedBy")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CompanyId");
|
|
||||||
|
|
||||||
b.ToTable("ShopWorkers");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorkerRoleCost", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
|
||||||
|
|
||||||
b.Property<int>("CompanyId")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("CreatedBy")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("DeletedBy")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.Property<decimal>("HourlyRate")
|
|
||||||
.HasColumnType("decimal(18,2)");
|
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
|
||||||
.HasColumnType("bit");
|
|
||||||
|
|
||||||
b.Property<int>("Role")
|
|
||||||
.HasColumnType("int");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
|
||||||
.HasColumnType("datetime2");
|
|
||||||
|
|
||||||
b.Property<string>("UpdatedBy")
|
|
||||||
.HasColumnType("nvarchar(max)");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "Role")
|
|
||||||
.IsUnique()
|
|
||||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
|
||||||
|
|
||||||
b.ToTable("ShopWorkerRoleCosts");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.StripeWebhookEvent", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -8742,6 +8730,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("YearEndCloses");
|
b.ToTable("YearEndCloses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("InventoryCategoryLookupId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("VendorId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("InventoryCategoryLookupId", "VendorId");
|
||||||
|
|
||||||
|
b.HasIndex("VendorId");
|
||||||
|
|
||||||
|
b.ToTable("VendorInventoryCategories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
@@ -9541,10 +9544,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
.HasForeignKey("PowderCoating.Core.Entities.Job", "QuoteId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
|
||||||
.WithMany("AssignedJobs")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("AssignedUser");
|
b.Navigation("AssignedUser");
|
||||||
|
|
||||||
b.Navigation("Customer");
|
b.Navigation("Customer");
|
||||||
@@ -9609,6 +9608,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.HasForeignKey("CatalogItemId")
|
.HasForeignKey("CatalogItemId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
|
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
|
||||||
.WithMany("JobItems")
|
.WithMany("JobItems")
|
||||||
.HasForeignKey("JobId")
|
.HasForeignKey("JobId")
|
||||||
@@ -9619,6 +9622,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("CatalogItem");
|
b.Navigation("CatalogItem");
|
||||||
|
|
||||||
|
b.Navigation("CustomItemTemplate");
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -9847,13 +9852,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", "Worker")
|
|
||||||
.WithMany("TimeEntries")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
|
|
||||||
b.Navigation("Worker");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.JournalEntry", b =>
|
||||||
@@ -9924,10 +9923,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("RecurrenceParentId");
|
.HasForeignKey("RecurrenceParentId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.ShopWorker", null)
|
|
||||||
.WithMany("AssignedMaintenanceTasks")
|
|
||||||
.HasForeignKey("ShopWorkerId");
|
|
||||||
|
|
||||||
b.Navigation("AssignedUser");
|
b.Navigation("AssignedUser");
|
||||||
|
|
||||||
b.Navigation("Equipment");
|
b.Navigation("Equipment");
|
||||||
@@ -10238,6 +10233,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CatalogItemId");
|
.HasForeignKey("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
|
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
|
||||||
.WithMany("QuoteItems")
|
.WithMany("QuoteItems")
|
||||||
.HasForeignKey("QuoteId")
|
.HasForeignKey("QuoteId")
|
||||||
@@ -10248,6 +10247,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("CatalogItem");
|
b.Navigation("CatalogItem");
|
||||||
|
|
||||||
|
b.Navigation("CustomItemTemplate");
|
||||||
|
|
||||||
b.Navigation("Quote");
|
b.Navigation("Quote");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -10411,15 +10412,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("ReworkJob");
|
b.Navigation("ReworkJob");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
|
||||||
.WithMany("ShopWorkers")
|
|
||||||
.HasForeignKey("CompanyId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict)
|
|
||||||
.IsRequired();
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -10503,6 +10495,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InventoryCategoryLookupId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VendorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillLineItems");
|
b.Navigation("BillLineItems");
|
||||||
@@ -10582,8 +10589,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("Quotes");
|
b.Navigation("Quotes");
|
||||||
|
|
||||||
b.Navigation("ShopWorkers");
|
|
||||||
|
|
||||||
b.Navigation("Users");
|
b.Navigation("Users");
|
||||||
|
|
||||||
b.Navigation("Vendors");
|
b.Navigation("Vendors");
|
||||||
@@ -10749,15 +10754,6 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Quotes");
|
b.Navigation("Quotes");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.ShopWorker", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("AssignedJobs");
|
|
||||||
|
|
||||||
b.Navigation("AssignedMaintenanceTasks");
|
|
||||||
|
|
||||||
b.Navigation("TimeEntries");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Vendor", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillPayments");
|
b.Navigation("BillPayments");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
||||||
|
{
|
||||||
|
return await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.CountAsync(j => j.OriginalJobId == originalJobId);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
// Customer Intake Kiosk
|
// Customer Intake Kiosk
|
||||||
private IRepository<KioskSession>? _kioskSessions;
|
private IRepository<KioskSession>? _kioskSessions;
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
private IRepository<CustomItemTemplate>? _customItemTemplates;
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
private IPurchaseOrderRepository? _purchaseOrders;
|
private IPurchaseOrderRepository? _purchaseOrders;
|
||||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||||
@@ -457,6 +460,10 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<KioskSession> KioskSessions =>
|
public IRepository<KioskSession> KioskSessions =>
|
||||||
_kioskSessions ??= new Repository<KioskSession>(_context);
|
_kioskSessions ??= new Repository<KioskSession>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<CustomItemTemplate> CustomItemTemplates =>
|
||||||
|
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
|
||||||
|
|
||||||
// Job Templates
|
// Job Templates
|
||||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||||
public IJobTemplateRepository JobTemplates =>
|
public IJobTemplateRepository JobTemplates =>
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using NCalc2;
|
||||||
|
using Anthropic.SDK;
|
||||||
|
using Anthropic.SDK.Messaging;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates NCalc pricing formula templates from natural-language descriptions using
|
||||||
|
/// Claude Sonnet. Accepts an optional diagram image so the model can see the physical
|
||||||
|
/// shape being estimated. The model returns a structured JSON object containing the
|
||||||
|
/// field list, NCalc expression, output mode, and verification inputs; the service
|
||||||
|
/// parses and returns it as a <see cref="GenerateFormulaFromAiResponse"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomFormulaAiService : ICustomFormulaAiService
|
||||||
|
{
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ILogger<CustomFormulaAiService> _logger;
|
||||||
|
|
||||||
|
private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business.
|
||||||
|
Your job is to generate NCalc expressions that calculate either a fixed price or a surface area
|
||||||
|
from user-supplied field values. NCalc supports standard math operators (+, -, *, /, %, Pow()),
|
||||||
|
comparison operators, and the Abs(), Round(), Max(), Min() built-in functions.
|
||||||
|
|
||||||
|
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
|
||||||
|
'Tubular frame') and you must produce a pricing formula template.
|
||||||
|
|
||||||
|
Respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation:
|
||||||
|
|
||||||
|
{
|
||||||
|
""name"": ""string — short template name (e.g. 'Roof Curb', 'Electrical Enclosure')"",
|
||||||
|
""outputMode"": ""FixedRate"" | ""SurfaceAreaSqFt"",
|
||||||
|
""fields"": [
|
||||||
|
{
|
||||||
|
""name"": ""snake_case_variable_name"",
|
||||||
|
""label"": ""Human-readable label"",
|
||||||
|
""unit"": ""in / ft / mm / cm / qty / lbs — or empty string"",
|
||||||
|
""defaultValue"": number
|
||||||
|
}
|
||||||
|
],
|
||||||
|
""formula"": ""NCalc expression using field name variables and optionally 'rate'"",
|
||||||
|
""defaultRate"": number or null,
|
||||||
|
""rateLabel"": ""string label for the rate field, e.g. '$/sq ft' — null if no rate"",
|
||||||
|
""reasoning"": ""1-2 sentences explaining how the formula was derived"",
|
||||||
|
""verificationInputs"": { ""variable_name"": number },
|
||||||
|
""verificationResult"": number
|
||||||
|
}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
|
||||||
|
- Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest
|
||||||
|
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions
|
||||||
|
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
|
||||||
|
- verificationInputs and verificationResult must use the exact field names and formula you generated
|
||||||
|
- Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed
|
||||||
|
- For inch inputs convert to sqft: divide by 144 (sqin→sqft) or use /12 per side before multiplying
|
||||||
|
";
|
||||||
|
|
||||||
|
public CustomFormulaAiService(IConfiguration config, ILogger<CustomFormulaAiService> logger)
|
||||||
|
{
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
|
||||||
|
GenerateFormulaFromAiRequest request,
|
||||||
|
byte[]? imageBytes = null,
|
||||||
|
string? imageContentType = null)
|
||||||
|
{
|
||||||
|
var apiKey = _config["AI:Anthropic:ApiKey"];
|
||||||
|
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
||||||
|
{
|
||||||
|
return new GenerateFormulaFromAiResponse
|
||||||
|
{
|
||||||
|
Success = false,
|
||||||
|
Error = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var client = new AnthropicClient(apiKey);
|
||||||
|
|
||||||
|
var userContent = new List<ContentBase>();
|
||||||
|
|
||||||
|
if (imageBytes is { Length: > 0 } && !string.IsNullOrWhiteSpace(imageContentType))
|
||||||
|
{
|
||||||
|
userContent.Add(new ImageContent
|
||||||
|
{
|
||||||
|
Source = new ImageSource
|
||||||
|
{
|
||||||
|
MediaType = imageContentType,
|
||||||
|
Data = Convert.ToBase64String(imageBytes)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
userContent.Add(new TextContent { Text = request.Description });
|
||||||
|
|
||||||
|
var messages = new List<Message>
|
||||||
|
{
|
||||||
|
new() { Role = RoleType.User, Content = userContent }
|
||||||
|
};
|
||||||
|
|
||||||
|
var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters
|
||||||
|
{
|
||||||
|
Model = "claude-sonnet-4-6",
|
||||||
|
MaxTokens = 1024,
|
||||||
|
SystemMessage = SystemPrompt,
|
||||||
|
Messages = messages
|
||||||
|
});
|
||||||
|
|
||||||
|
var rawJson = response.Message.ToString().Trim();
|
||||||
|
|
||||||
|
// Strip markdown code fences if the model adds them
|
||||||
|
if (rawJson.StartsWith("```"))
|
||||||
|
{
|
||||||
|
var start = rawJson.IndexOf('\n') + 1;
|
||||||
|
var end = rawJson.LastIndexOf("```");
|
||||||
|
if (end > start) rawJson = rawJson[start..end].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(rawJson);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
var fieldsJson = root.TryGetProperty("fields", out var fieldsEl)
|
||||||
|
? fieldsEl.GetRawText()
|
||||||
|
: "[]";
|
||||||
|
|
||||||
|
decimal? defaultRate = null;
|
||||||
|
if (root.TryGetProperty("defaultRate", out var rateEl) && rateEl.ValueKind == JsonValueKind.Number)
|
||||||
|
defaultRate = rateEl.GetDecimal();
|
||||||
|
|
||||||
|
decimal? verificationResult = null;
|
||||||
|
if (root.TryGetProperty("verificationResult", out var vrEl) && vrEl.ValueKind == JsonValueKind.Number)
|
||||||
|
verificationResult = vrEl.GetDecimal();
|
||||||
|
|
||||||
|
string? verificationInputs = null;
|
||||||
|
if (root.TryGetProperty("verificationInputs", out var viEl))
|
||||||
|
verificationInputs = viEl.GetRawText();
|
||||||
|
|
||||||
|
return new GenerateFormulaFromAiResponse
|
||||||
|
{
|
||||||
|
Success = true,
|
||||||
|
Name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
|
||||||
|
OutputMode = root.TryGetProperty("outputMode", out var omEl) ? omEl.GetString() : "FixedRate",
|
||||||
|
FieldsJson = fieldsJson,
|
||||||
|
Formula = root.TryGetProperty("formula", out var fEl) ? fEl.GetString() : null,
|
||||||
|
DefaultRate = defaultRate,
|
||||||
|
RateLabel = root.TryGetProperty("rateLabel", out var rlEl) ? rlEl.GetString() : null,
|
||||||
|
Reasoning = root.TryGetProperty("reasoning", out var reEl) ? reEl.GetString() : null,
|
||||||
|
VerificationResult = verificationResult,
|
||||||
|
VerificationInputs = verificationInputs
|
||||||
|
};
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "CustomFormulaAiService.GenerateFormulaAsync failed");
|
||||||
|
return new GenerateFormulaFromAiResponse { Success = false, Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request?.Formula))
|
||||||
|
return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." };
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
||||||
|
request.VariablesJson ?? "{}") ?? new();
|
||||||
|
|
||||||
|
var expr = new Expression(request.Formula);
|
||||||
|
foreach (var kv in variables)
|
||||||
|
{
|
||||||
|
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
||||||
|
? (object)kv.Value.GetDecimal()
|
||||||
|
: (object)(kv.Value.GetString() ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = expr.Evaluate();
|
||||||
|
var decResult = Convert.ToDecimal(result);
|
||||||
|
return new EvaluateFormulaResponse { Success = true, Result = Math.Round(decResult, 4) };
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return new EvaluateFormulaResponse { Success = false, Error = ex.Message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1209,6 +1209,15 @@ Rules:
|
|||||||
sb.AppendLine("Page content:");
|
sb.AppendLine("Page content:");
|
||||||
sb.AppendLine(pageContent);
|
sb.AppendLine(pageContent);
|
||||||
}
|
}
|
||||||
|
else if (!string.IsNullOrWhiteSpace(fetchUrl))
|
||||||
|
{
|
||||||
|
// Page content unavailable (fetch failed or blocked) — still surface the URL so Claude
|
||||||
|
// can use its training knowledge of the manufacturer URL structure (e.g. Prismatic SKU
|
||||||
|
// in the path) to infer product identity rather than returning all-null fields.
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"Product URL (page content could not be fetched): {fetchUrl}");
|
||||||
|
sb.AppendLine("Use your training knowledge of this manufacturer and the URL to fill in as many fields as possible.");
|
||||||
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1152,6 +1152,156 @@ public class NotificationService : INotificationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends appointment reminder emails when an appointment's reminder window opens.
|
||||||
|
/// Two emails are dispatched independently:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item>Customer email — sent when a customer is linked, has an email address, and has
|
||||||
|
/// email notifications enabled (<see cref="Customer.NotifyByEmail"/>).</item>
|
||||||
|
/// <item>Staff email — sent to <see cref="BaseEntity.CreatedBy"/> (the user who created
|
||||||
|
/// the appointment). This fires regardless of whether a customer is linked.</item>
|
||||||
|
/// </list>
|
||||||
|
/// Called exclusively by
|
||||||
|
/// <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||||
|
/// after it stamps <c>ReminderSentAt</c> — the caller owns deduplication.
|
||||||
|
/// </summary>
|
||||||
|
public async Task NotifyAppointmentReminderAsync(Appointment appointment)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var (companyName, company) = await GetCompanyAsync(appointment.CompanyId);
|
||||||
|
var (replyToEmail, replyToName) = await GetEmailFromAsync(appointment.CompanyId);
|
||||||
|
var baseUrl = await GetBaseUrlAsync();
|
||||||
|
|
||||||
|
var locationLine = !string.IsNullOrWhiteSpace(appointment.Location)
|
||||||
|
? $"<br/><strong>Location:</strong> {WebUtility.HtmlEncode(appointment.Location)}"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var appointmentDate = appointment.ScheduledStartTime.ToString("dddd, MMMM d, yyyy");
|
||||||
|
var appointmentTime = appointment.IsAllDay
|
||||||
|
? "All Day"
|
||||||
|
: appointment.ScheduledStartTime.ToString("h:mm tt");
|
||||||
|
var defaultSubject = $"Appointment Reminder — {appointment.Title} on {appointment.ScheduledStartTime:MMMM d, yyyy}";
|
||||||
|
|
||||||
|
// ── Customer email ────────────────────────────────────────────────
|
||||||
|
if (appointment.CustomerId != null)
|
||||||
|
{
|
||||||
|
var customer = appointment.Customer
|
||||||
|
?? await _context.Customers.FindAsync(appointment.CustomerId.Value);
|
||||||
|
|
||||||
|
if (customer != null)
|
||||||
|
{
|
||||||
|
var customerName = GetCustomerDisplayName(customer);
|
||||||
|
var reminderEmails = ParseEmailList(customer.Email);
|
||||||
|
|
||||||
|
if (!customer.NotifyByEmail || reminderEmails.Count == 0)
|
||||||
|
{
|
||||||
|
if (reminderEmails.Count > 0)
|
||||||
|
{
|
||||||
|
await WriteLog(SkippedLog(NotificationChannel.Email, NotificationType.AppointmentReminder,
|
||||||
|
customerName, string.Join(", ", reminderEmails), appointment.CompanyId,
|
||||||
|
customerId: customer.Id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var customerValues = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["companyName"] = companyName,
|
||||||
|
["customerName"] = customerName,
|
||||||
|
["appointmentTitle"] = appointment.Title,
|
||||||
|
["appointmentDate"] = appointmentDate,
|
||||||
|
["appointmentTime"] = appointmentTime,
|
||||||
|
["locationLine"] = locationLine
|
||||||
|
};
|
||||||
|
|
||||||
|
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||||
|
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||||
|
|
||||||
|
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
||||||
|
var custPlainText = StripHtml(custFullHtml);
|
||||||
|
|
||||||
|
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||||
|
customer.Email, customerName, custSubject, custPlainText, custFullHtml,
|
||||||
|
replyToEmail: replyToEmail, replyToName: replyToName);
|
||||||
|
|
||||||
|
await WriteLog(new NotificationLog
|
||||||
|
{
|
||||||
|
Channel = NotificationChannel.Email,
|
||||||
|
NotificationType = NotificationType.AppointmentReminder,
|
||||||
|
Status = custOk ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||||
|
RecipientName = customerName,
|
||||||
|
Recipient = custLog,
|
||||||
|
Subject = custSubject,
|
||||||
|
Message = custPlainText,
|
||||||
|
ErrorMessage = custErr,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
CompanyId = appointment.CompanyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Staff email ───────────────────────────────────────────────────
|
||||||
|
// Send to whoever created the appointment so they get an out-of-app reminder.
|
||||||
|
if (!string.IsNullOrWhiteSpace(appointment.CreatedBy))
|
||||||
|
{
|
||||||
|
// Look up the user's display name from Identity if available.
|
||||||
|
var staffUser = await _context.Users
|
||||||
|
.FirstOrDefaultAsync(u => u.Email == appointment.CreatedBy);
|
||||||
|
var staffName = !string.IsNullOrWhiteSpace(staffUser?.FullName)
|
||||||
|
? staffUser.FullName
|
||||||
|
: appointment.CreatedBy;
|
||||||
|
|
||||||
|
// Include a customer line only when a customer is linked.
|
||||||
|
var customerLine = appointment.Customer != null
|
||||||
|
? $"<br/><strong>Customer:</strong> {WebUtility.HtmlEncode(GetCustomerDisplayName(appointment.Customer))}"
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
|
var staffValues = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["companyName"] = companyName,
|
||||||
|
["staffName"] = staffName,
|
||||||
|
["appointmentTitle"] = appointment.Title,
|
||||||
|
["appointmentDate"] = appointmentDate,
|
||||||
|
["appointmentTime"] = appointmentTime,
|
||||||
|
["customerLine"] = customerLine,
|
||||||
|
["locationLine"] = locationLine
|
||||||
|
};
|
||||||
|
|
||||||
|
var staffDefaultSubject = $"[Reminder] {appointment.Title} — {appointment.ScheduledStartTime:MMMM d, yyyy 'at' h:mm tt}";
|
||||||
|
|
||||||
|
var (staffSubject, staffHtml) = await GetRenderedEmailAsync(
|
||||||
|
appointment.CompanyId, NotificationType.AppointmentReminderStaff, staffValues, staffDefaultSubject);
|
||||||
|
|
||||||
|
var staffPlainText = StripHtml(staffHtml);
|
||||||
|
|
||||||
|
var (staffOk, staffErr, staffLog) = await SendToEmailListAsync(
|
||||||
|
appointment.CreatedBy, staffName, staffSubject, staffPlainText, staffHtml,
|
||||||
|
replyToEmail: replyToEmail, replyToName: replyToName);
|
||||||
|
|
||||||
|
await WriteLog(new NotificationLog
|
||||||
|
{
|
||||||
|
Channel = NotificationChannel.Email,
|
||||||
|
NotificationType = NotificationType.AppointmentReminderStaff,
|
||||||
|
Status = staffOk ? NotificationStatus.Sent : NotificationStatus.Failed,
|
||||||
|
RecipientName = staffName,
|
||||||
|
Recipient = staffLog,
|
||||||
|
Subject = staffSubject,
|
||||||
|
Message = staffPlainText,
|
||||||
|
ErrorMessage = staffErr,
|
||||||
|
SentAt = DateTime.UtcNow,
|
||||||
|
CompanyId = appointment.CompanyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "NotifyAppointmentReminderAsync failed for appointment {AppointmentId}", appointment.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Fallback default templates (used when company has no DB template)
|
// Fallback default templates (used when company has no DB template)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -1217,6 +1367,14 @@ public class NotificationService : INotificationService
|
|||||||
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
|
"Payment Reminder — Invoice {{invoiceNumber}} ({{daysOverdue}} days overdue)",
|
||||||
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
|
"<p>Dear {{customerName}},</p><p>This is a friendly reminder that invoice <strong>{{invoiceNumber}}</strong> for <strong>{{invoiceTotal}}</strong> was due on <strong>{{dueDate}}</strong> and is now <strong>{{daysOverdue}} days overdue</strong>.</p><p>Outstanding balance: <strong>{{balanceDue}}</strong></p><p>Please arrange payment at your earliest convenience. If you have already sent payment, please disregard this notice.</p><p>Thank you for your business with {{companyName}}.</p>"
|
||||||
),
|
),
|
||||||
|
[(NotificationType.AppointmentReminder, NotificationChannel.Email)] = (
|
||||||
|
"Appointment Reminder — {{appointmentTitle}} on {{appointmentDate}}",
|
||||||
|
"<p>Dear {{customerName}},</p><p>This is a reminder that you have an upcoming appointment with <strong>{{companyName}}</strong>.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{locationLine}}</p><p>If you have any questions or need to reschedule, please contact us at your earliest convenience.</p><p>Thank you for choosing {{companyName}}.</p>"
|
||||||
|
),
|
||||||
|
[(NotificationType.AppointmentReminderStaff, NotificationChannel.Email)] = (
|
||||||
|
"[Reminder] {{appointmentTitle}} — {{appointmentDate}}",
|
||||||
|
"<p>Hi {{staffName}},</p><p>This is a reminder that you have an upcoming appointment.</p><p><strong>Appointment:</strong> {{appointmentTitle}}<br/><strong>Date & Time:</strong> {{appointmentDate}} at {{appointmentTime}}{{customerLine}}{{locationLine}}</p><p>— {{companyName}}</p>"
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
public static (string? Subject, string Body)? Get(NotificationType type, NotificationChannel channel)
|
||||||
|
|||||||
@@ -162,7 +162,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal invoiceTotal,
|
decimal invoiceTotal,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string invoiceNumber,
|
string invoiceNumber,
|
||||||
int invoiceId)
|
int invoiceId)
|
||||||
{
|
{
|
||||||
@@ -175,7 +174,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Invoice {invoiceNumber}",
|
Description = $"Invoice {invoiceNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
@@ -215,7 +213,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
decimal depositAmount,
|
decimal depositAmount,
|
||||||
decimal surchargeAmount,
|
decimal surchargeAmount,
|
||||||
string currency,
|
string currency,
|
||||||
string customerEmail,
|
|
||||||
string quoteNumber,
|
string quoteNumber,
|
||||||
int quoteId)
|
int quoteId)
|
||||||
{
|
{
|
||||||
@@ -228,7 +225,6 @@ public class StripeConnectService : IStripeConnectService
|
|||||||
{
|
{
|
||||||
Amount = amountInCents,
|
Amount = amountInCents,
|
||||||
Currency = currency.ToLower(),
|
Currency = currency.ToLower(),
|
||||||
ReceiptEmail = customerEmail,
|
|
||||||
Description = $"Deposit for quote {quoteNumber}",
|
Description = $"Deposit for quote {quoteNumber}",
|
||||||
Metadata = new Dictionary<string, string>
|
Metadata = new Dictionary<string, string>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Polls every 60 seconds for appointments whose reminder window has opened and dispatches
|
||||||
|
/// an email to the linked customer plus an in-app bell notification to company staff.
|
||||||
|
///
|
||||||
|
/// Deduplication strategy: after selecting candidates the service immediately stamps
|
||||||
|
/// <c>ReminderSentAt</c> on each appointment and saves before calling the notification
|
||||||
|
/// methods. This prevents a second loop iteration from re-sending if notifications are slow
|
||||||
|
/// or the application restarts mid-batch. A 24-hour lookback window caps the query so that
|
||||||
|
/// appointments that slipped through (e.g., server downtime) are silently skipped rather
|
||||||
|
/// than sending a stale reminder.
|
||||||
|
/// </summary>
|
||||||
|
public class AppointmentReminderBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<AppointmentReminderBackgroundService> _logger;
|
||||||
|
|
||||||
|
private static readonly TimeSpan PollingInterval = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Appointments whose scheduled start is more than this far in the past are ignored even
|
||||||
|
/// if their reminder was never sent (server was down, etc.). We do not want to blast a
|
||||||
|
/// customer with a "your appointment is in 30 minutes" email hours after it was due.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly TimeSpan MaxLookback = TimeSpan.FromHours(24);
|
||||||
|
|
||||||
|
public AppointmentReminderBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<AppointmentReminderBackgroundService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Long-running loop that wakes every <see cref="PollingInterval"/> (60 s) and calls
|
||||||
|
/// <see cref="RunAsync"/>. Uses <see cref="Task.Delay"/> with the cancellation token so
|
||||||
|
/// the service shuts down promptly when the application stops.
|
||||||
|
/// </summary>
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("AppointmentReminderBackgroundService started.");
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(PollingInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
await RunAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("AppointmentReminderBackgroundService stopped.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One poll iteration: find all appointments whose reminder window has opened, stamp them,
|
||||||
|
/// then dispatch email + in-app notifications. A fresh DI scope is created per poll so that
|
||||||
|
/// the DbContext change tracker is clean each time.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||||
|
var notificationService = scope.ServiceProvider.GetRequiredService<INotificationService>();
|
||||||
|
var inAppService = scope.ServiceProvider.GetRequiredService<IInAppNotificationService>();
|
||||||
|
|
||||||
|
// ScheduledStartTime is stored as server-local time (no UTC conversion on form submit),
|
||||||
|
// so compare against DateTime.Now rather than UtcNow to avoid a 4-hour EDT offset.
|
||||||
|
var now = DateTime.Now;
|
||||||
|
var lookback = now - MaxLookback;
|
||||||
|
|
||||||
|
// Find appointments where:
|
||||||
|
// - Reminder is enabled and has not been sent yet
|
||||||
|
// - The reminder window has opened: ScheduledStartTime - ReminderMinutesBefore <= now
|
||||||
|
// - The appointment hasn't been sitting unprocessed for more than MaxLookback
|
||||||
|
// - The appointment status is not terminal (not cancelled, completed, no-show, etc.)
|
||||||
|
// IgnoreQueryFilters bypasses the tenant filter — no HTTP context in a background service.
|
||||||
|
var candidates = await db.Appointments
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Include(a => a.Customer)
|
||||||
|
.Include(a => a.AppointmentStatus)
|
||||||
|
.Where(a =>
|
||||||
|
!a.IsDeleted &&
|
||||||
|
a.IsReminderEnabled &&
|
||||||
|
a.ReminderSentAt == null &&
|
||||||
|
a.ScheduledStartTime > lookback &&
|
||||||
|
EF.Functions.DateDiffMinute(now, a.ScheduledStartTime) <= a.ReminderMinutesBefore &&
|
||||||
|
!a.AppointmentStatus.IsTerminalStatus)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (candidates.Count == 0) return;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"AppointmentReminderBackgroundService: {Count} appointment reminder(s) to dispatch.",
|
||||||
|
candidates.Count);
|
||||||
|
|
||||||
|
// Stamp ReminderSentAt before sending — prevents a restart from re-sending.
|
||||||
|
var stampedAt = now;
|
||||||
|
foreach (var appt in candidates)
|
||||||
|
appt.ReminderSentAt = stampedAt;
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Now send notifications. Failures here don't roll back the stamp because we'd
|
||||||
|
// rather skip one reminder than spam a customer on every restart.
|
||||||
|
foreach (var appt in candidates)
|
||||||
|
{
|
||||||
|
if (ct.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Email to linked customer (no-ops internally if customer has opted out)
|
||||||
|
await notificationService.NotifyAppointmentReminderAsync(appt);
|
||||||
|
|
||||||
|
// In-app bell notification for company staff
|
||||||
|
var when = appt.IsAllDay
|
||||||
|
? appt.ScheduledStartTime.ToString("MMMM d, yyyy")
|
||||||
|
: appt.ScheduledStartTime.ToString("MMMM d, yyyy 'at' h:mm tt");
|
||||||
|
|
||||||
|
await inAppService.CreateAsync(
|
||||||
|
companyId: appt.CompanyId,
|
||||||
|
title: $"Appointment Reminder: {appt.Title}",
|
||||||
|
message: $"{appt.AppointmentNumber} is scheduled for {when}.",
|
||||||
|
notificationType: "AppointmentReminder",
|
||||||
|
link: $"/Appointments/Details/{appt.Id}",
|
||||||
|
customerId: appt.CustomerId);
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Reminder dispatched for appointment {AppointmentNumber} (id {Id}, company {CompanyId}).",
|
||||||
|
appt.AppointmentNumber, appt.Id, appt.CompanyId);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex,
|
||||||
|
"Failed to dispatch reminder for appointment {AppointmentId}.", appt.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "AppointmentReminderBackgroundService poll failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,10 +60,11 @@ public class AccountingExportController : Controller
|
|||||||
{
|
{
|
||||||
var start = startDate.Date;
|
var start = startDate.Date;
|
||||||
var end = endDate.Date.AddDays(1).AddTicks(-1);
|
var end = endDate.Date.AddDays(1).AddTicks(-1);
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// ── Load data ─────────────────────────────────────────────────────────
|
// ── Load data ─────────────────────────────────────────────────────────
|
||||||
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
var invoices = (await _unitOfWork.Invoices.FindAsync(
|
||||||
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
|
i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
|
||||||
false,
|
false,
|
||||||
i => i.InvoiceItems,
|
i => i.InvoiceItems,
|
||||||
i => i.Payments,
|
i => i.Payments,
|
||||||
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
var expenses = (await _unitOfWork.Expenses.FindAsync(
|
||||||
e => e.Date >= start && e.Date <= end,
|
e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
|
||||||
false,
|
false,
|
||||||
e => e.Vendor,
|
e => e.Vendor,
|
||||||
e => e.ExpenseAccount,
|
e => e.ExpenseAccount,
|
||||||
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
|
|||||||
|
|
||||||
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
|
||||||
|
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
|
||||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -381,9 +381,15 @@ public class AppointmentsController : Controller
|
|||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map changes
|
// Map changes — capture old start before overwrite so we can detect a reschedule.
|
||||||
|
var previousStart = appointment.ScheduledStartTime;
|
||||||
_mapper.Map(dto, appointment);
|
_mapper.Map(dto, appointment);
|
||||||
|
|
||||||
|
// If the appointment was rescheduled, clear the reminder stamp so the background
|
||||||
|
// service will fire again at the new time.
|
||||||
|
if (appointment.ScheduledStartTime != previousStart)
|
||||||
|
appointment.ReminderSentAt = null;
|
||||||
|
|
||||||
// Update
|
// Update
|
||||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -486,9 +492,12 @@ public class AppointmentsController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var events = new List<CalendarEventDto>();
|
var events = new List<CalendarEventDto>();
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// 1. Fetch appointments in date range
|
// 1. Fetch appointments in date range
|
||||||
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false,
|
var allAppointments = await _unitOfWork.Appointments.FindAsync(
|
||||||
|
a => a.CompanyId == companyId,
|
||||||
|
false,
|
||||||
a => a.Customer,
|
a => a.Customer,
|
||||||
a => a.AppointmentType,
|
a => a.AppointmentType,
|
||||||
a => a.AppointmentStatus);
|
a => a.AppointmentStatus);
|
||||||
@@ -501,7 +510,9 @@ public class AppointmentsController : Controller
|
|||||||
events.AddRange(appointmentEvents);
|
events.AddRange(appointmentEvents);
|
||||||
|
|
||||||
// 2. Fetch maintenance records in date range
|
// 2. Fetch maintenance records in date range
|
||||||
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.GetAllAsync(false,
|
var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||||
|
m => m.CompanyId == companyId,
|
||||||
|
false,
|
||||||
m => m.Equipment);
|
m => m.Equipment);
|
||||||
|
|
||||||
var maintenanceRecords = allMaintenanceRecords
|
var maintenanceRecords = allMaintenanceRecords
|
||||||
@@ -539,7 +550,9 @@ public class AppointmentsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Fetch jobs and add as all-day events
|
// 3. Fetch jobs and add as all-day events
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => j.CompanyId == companyId,
|
||||||
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus);
|
j => j.JobStatus);
|
||||||
|
|
||||||
@@ -718,6 +731,8 @@ public class AppointmentsController : Controller
|
|||||||
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
var duration = appointment.ScheduledEndTime - appointment.ScheduledStartTime;
|
||||||
appointment.ScheduledStartTime = start;
|
appointment.ScheduledStartTime = start;
|
||||||
appointment.ScheduledEndTime = end;
|
appointment.ScheduledEndTime = end;
|
||||||
|
// Drag-drop always changes the time — reset so the reminder fires at the new time.
|
||||||
|
appointment.ReminderSentAt = null;
|
||||||
|
|
||||||
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
await _unitOfWork.Appointments.UpdateAsync(appointment);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -746,13 +761,16 @@ public class AppointmentsController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled };
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(false,
|
var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => j.CompanyId == calCompanyId,
|
||||||
|
false,
|
||||||
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
j => j.Customer, j => j.JobStatus, j => j.JobItems);
|
||||||
|
|
||||||
// Load coats separately — filter by JobItemId using already-loaded item IDs
|
// Load coats separately — filter by JobItemId using already-loaded item IDs
|
||||||
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
|
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
|
||||||
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
|
var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
|
||||||
c => jobItemIds.Contains(c.JobItemId));
|
c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
|
||||||
|
|
||||||
var coatsByItemId = allCoats
|
var coatsByItemId = allCoats
|
||||||
.Where(c => !c.IsDeleted)
|
.Where(c => !c.IsDeleted)
|
||||||
@@ -891,7 +909,9 @@ public class AppointmentsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCreateDropdowns()
|
private async Task PopulateCreateDropdowns()
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
var customerList = customers.Select(c => new
|
var customerList = customers.Select(c => new
|
||||||
{
|
{
|
||||||
c.Id,
|
c.Id,
|
||||||
@@ -903,19 +923,16 @@ public class AppointmentsController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
|
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
|
||||||
|
|
||||||
// Use cached appointment types
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
|
||||||
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
|
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName");
|
||||||
|
|
||||||
var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var workers = await _userManager.Users
|
var workers = await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyIdForWorkers && u.IsActive && u.CompanyRole != null)
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
|
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName");
|
||||||
|
|
||||||
var jobs = await _unitOfWork.Jobs.GetAllAsync();
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId);
|
||||||
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
|
ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly ILogger<CatalogCategoriesController> _logger;
|
private readonly ILogger<CatalogCategoriesController> _logger;
|
||||||
|
|
||||||
public CatalogCategoriesController(
|
public CatalogCategoriesController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
|
ITenantContext tenantContext,
|
||||||
ILogger<CatalogCategoriesController> logger)
|
ILogger<CatalogCategoriesController> logger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var categories = await _unitOfWork.CatalogCategories
|
var categories = await _unitOfWork.CatalogCategories
|
||||||
.GetAllAsync(false,
|
.FindAsync(c => c.CompanyId == indexCompanyId, false,
|
||||||
c => c.ParentCategory,
|
c => c.ParentCategory,
|
||||||
c => c.SubCategories,
|
c => c.SubCategories,
|
||||||
c => c.Items);
|
c => c.Items);
|
||||||
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
if (ModelState.IsValid)
|
if (ModelState.IsValid)
|
||||||
{
|
{
|
||||||
// Check for duplicate category name under the same parent (case-insensitive)
|
// Check for duplicate category name under the same parent (case-insensitive)
|
||||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId);
|
||||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||||
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||||
c.ParentCategoryId == dto.ParentCategoryId);
|
c.ParentCategoryId == dto.ParentCategoryId);
|
||||||
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
|
|
||||||
if (nameChanged || parentChanged)
|
if (nameChanged || parentChanged)
|
||||||
{
|
{
|
||||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == editCompanyId);
|
||||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||||
c.Id != id &&
|
c.Id != id &&
|
||||||
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
|
||||||
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
var trimmedName = request.Name.Trim();
|
var trimmedName = request.Name.Trim();
|
||||||
|
|
||||||
// Check for duplicate category name under the same parent (case-insensitive)
|
// Check for duplicate category name under the same parent (case-insensitive)
|
||||||
var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var quickCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == quickCompanyId);
|
||||||
var existingCategory = allCategories.FirstOrDefault(c =>
|
var existingCategory = allCategories.FirstOrDefault(c =>
|
||||||
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
|
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
|
||||||
c.ParentCategoryId == request.ParentCategoryId);
|
c.ParentCategoryId == request.ParentCategoryId);
|
||||||
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var categories = await _unitOfWork.CatalogCategories
|
var categories = await _unitOfWork.CatalogCategories
|
||||||
.GetAllAsync(false, c => c.SubCategories, c => c.Items);
|
.FindAsync(c => c.CompanyId == treeCompanyId, false, c => c.SubCategories, c => c.Items);
|
||||||
|
|
||||||
// Build tree from root categories
|
// Build tree from root categories
|
||||||
var rootCategories = categories
|
var rootCategories = categories
|
||||||
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var dropdownCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == dropdownCompanyId)).ToList();
|
||||||
|
|
||||||
// Build hierarchical list (parents before children)
|
// Build hierarchical list (parents before children)
|
||||||
var hierarchicalList = new List<CatalogCategory>();
|
var hierarchicalList = new List<CatalogCategory>();
|
||||||
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
/// </param>
|
/// </param>
|
||||||
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
|
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null)
|
||||||
{
|
{
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var parentDropCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == parentDropCompanyId)).ToList();
|
||||||
|
|
||||||
// Exclude the current category and its descendants to prevent circular references
|
// Exclude the current category and its descendants to prevent circular references
|
||||||
var excludedIds = new HashSet<int>();
|
var excludedIds = new HashSet<int>();
|
||||||
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
if (categoryId == newParentId)
|
if (categoryId == newParentId)
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var circleCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == circleCompanyId)).ToList();
|
||||||
var current = categories.FirstOrDefault(c => c.Id == newParentId);
|
var current = categories.FirstOrDefault(c => c.Id == newParentId);
|
||||||
|
|
||||||
while (current != null)
|
while (current != null)
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Get all categories with their items
|
// Get all categories with their items
|
||||||
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList();
|
var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList();
|
||||||
var allItems = allCategories.SelectMany(c => c.Items).ToList();
|
var allItems = allCategories.SelectMany(c => c.Items).ToList();
|
||||||
|
|
||||||
// Apply search filter
|
// Apply search filter
|
||||||
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
return Json(new List<object>());
|
return Json(new List<object>());
|
||||||
}
|
}
|
||||||
|
|
||||||
var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category);
|
var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category);
|
||||||
var search = searchTerm.ToLower();
|
var search = searchTerm.ToLower();
|
||||||
|
|
||||||
var items = allItems
|
var items = allItems
|
||||||
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCategoryDropdown()
|
private async Task PopulateCategoryDropdown()
|
||||||
{
|
{
|
||||||
var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Build hierarchical list (parents before children)
|
// Build hierarchical list (parents before children)
|
||||||
var hierarchicalList = new List<CatalogCategory>();
|
var hierarchicalList = new List<CatalogCategory>();
|
||||||
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
|
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms").
|
||||||
// The full path gives Claude the coating-type context it needs — an item in
|
// The full path gives Claude the coating-type context it needs — an item in
|
||||||
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
|
// "Firearms" under "Cerakote" costs very differently than one under "Powder Coat".
|
||||||
var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync())
|
var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId))
|
||||||
.ToDictionary(c => c.Id);
|
.ToDictionary(c => c.Id);
|
||||||
|
|
||||||
// Load company operating costs
|
// Load company operating costs
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ public class CompanySettingsController : Controller
|
|||||||
private readonly IAuditLogService _auditLog;
|
private readonly IAuditLogService _auditLog;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||||
|
private readonly IAzureBlobStorageService _blobStorage;
|
||||||
|
private readonly ICustomFormulaAiService _formulaAiService;
|
||||||
|
|
||||||
public CompanySettingsController(
|
public CompanySettingsController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -45,7 +47,9 @@ public class CompanySettingsController : Controller
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IAuditLogService auditLog,
|
IAuditLogService auditLog,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
SignInManager<ApplicationUser> signInManager)
|
SignInManager<ApplicationUser> signInManager,
|
||||||
|
IAzureBlobStorageService blobStorage,
|
||||||
|
ICustomFormulaAiService formulaAiService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
@@ -58,6 +62,8 @@ public class CompanySettingsController : Controller
|
|||||||
_auditLog = auditLog;
|
_auditLog = auditLog;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_signInManager = signInManager;
|
_signInManager = signInManager;
|
||||||
|
_blobStorage = blobStorage;
|
||||||
|
_formulaAiService = formulaAiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -142,10 +148,10 @@ public class CompanySettingsController : Controller
|
|||||||
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Load notification templates for inline tab
|
// Load notification templates for inline tab
|
||||||
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||||
if (seeded > 0)
|
if (seeded > 0)
|
||||||
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
dto.NotificationTemplates = existing
|
dto.NotificationTemplates = existing
|
||||||
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
||||||
@@ -755,8 +761,8 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
var costs = company.OperatingCosts;
|
var costs = company.OperatingCosts;
|
||||||
|
|
||||||
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList();
|
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList();
|
||||||
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList();
|
var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
|
||||||
|
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
|
|
||||||
@@ -920,7 +926,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||||
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
|
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
|
||||||
@@ -1071,7 +1078,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0));
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1084,7 +1092,6 @@ public class CompanySettingsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
|
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
|
||||||
|
|
||||||
_logger.LogInformation("Job statuses reordered");
|
_logger.LogInformation("Job statuses reordered");
|
||||||
@@ -1113,7 +1120,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||||
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
|
var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
|
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
|
||||||
@@ -1258,7 +1266,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1297,7 +1306,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||||
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
|
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
|
||||||
@@ -1478,7 +1488,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1517,7 +1528,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
|
||||||
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
|
var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
|
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
|
||||||
@@ -1639,7 +1651,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var services = await _unitOfWork.PrepServices.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1812,7 +1825,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
|
||||||
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
|
var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
|
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
|
||||||
@@ -1956,7 +1970,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -1996,7 +2011,8 @@ public class CompanySettingsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
|
var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
|
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
|
||||||
@@ -2132,7 +2148,8 @@ public class CompanySettingsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return Json(new { success = false, message = "Invalid data" });
|
return Json(new { success = false, message = "Invalid data" });
|
||||||
|
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
|
|
||||||
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
for (int i = 0; i < dto.OrderedIds.Count; i++)
|
||||||
{
|
{
|
||||||
@@ -2349,12 +2366,12 @@ public class CompanySettingsController : Controller
|
|||||||
if (companyId == null) return RedirectToAction(nameof(Index));
|
if (companyId == null) return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
// Load all existing templates for this company
|
// Load all existing templates for this company
|
||||||
var existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
// Auto-seed any missing canonical combinations
|
// Auto-seed any missing canonical combinations
|
||||||
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
|
||||||
if (seeded > 0)
|
if (seeded > 0)
|
||||||
existing = await _unitOfWork.NotificationTemplates.GetAllAsync();
|
existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
|
||||||
.Select(t => new NotificationTemplateDto
|
.Select(t => new NotificationTemplateDto
|
||||||
@@ -2951,6 +2968,218 @@ public class CompanySettingsController : Controller
|
|||||||
return RedirectToAction(nameof(DeleteAccount));
|
return RedirectToAction(nameof(DeleteAccount));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Custom Formula Item Templates ──────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetCustomItemTemplate(int id)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
var dto = _mapper.Map<CustomItemTemplateDto>(entity);
|
||||||
|
return Json(new { success = true, template = dto });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns all active + inactive formula templates for the current company.</summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> GetCustomItemTemplates()
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId);
|
||||||
|
var dtos = _mapper.Map<List<CustomItemTemplateListDto>>(templates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name));
|
||||||
|
return Json(new { success = true, templates = dtos });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates a new formula template for the current company.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Json(new { success = false, message = "Invalid data." });
|
||||||
|
|
||||||
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
|
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = _mapper.Map<CustomItemTemplate>(dto);
|
||||||
|
entity.CompanyId = companyId;
|
||||||
|
entity.CreatedAt = DateTime.UtcNow;
|
||||||
|
|
||||||
|
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, id = entity.Id });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Updates an existing formula template owned by the current company.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> UpdateCustomItemTemplate([FromBody] UpdateCustomItemTemplateDto dto)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return Json(new { success = false, message = "Invalid data." });
|
||||||
|
|
||||||
|
var fieldError = ValidateTemplateFields(dto.FieldsJson);
|
||||||
|
if (fieldError != null) return Json(new { success = false, message = fieldError });
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(dto.Id);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
|
||||||
|
_mapper.Map(dto, entity);
|
||||||
|
entity.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Soft-deletes a formula template owned by the current company.</summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> DeleteCustomItemTemplate(int id)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(id);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
|
||||||
|
await _unitOfWork.CustomItemTemplates.SoftDeleteAsync(id);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
return Json(new { success = true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads a diagram image for a template to blob storage container
|
||||||
|
/// <c>formulatemplate-diagrams/{companyId}/{templateId}/diagram.{ext}</c>.
|
||||||
|
/// Returns the blob path for storage on the entity.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> UploadTemplateDiagram(int templateId, IFormFile diagramFile)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||||
|
if (entity == null || entity.CompanyId != companyId)
|
||||||
|
return Json(new { success = false, message = "Template not found." });
|
||||||
|
|
||||||
|
var allowedTypes = new[] { "image/jpeg", "image/png", "image/gif", "image/webp" };
|
||||||
|
if (!allowedTypes.Contains(diagramFile.ContentType.ToLowerInvariant()))
|
||||||
|
return Json(new { success = false, message = "Only JPEG, PNG, GIF, or WebP images are allowed." });
|
||||||
|
|
||||||
|
if (diagramFile.Length > 10 * 1024 * 1024)
|
||||||
|
return Json(new { success = false, message = "Image must be under 10 MB." });
|
||||||
|
|
||||||
|
var ext = Path.GetExtension(diagramFile.FileName).ToLowerInvariant().TrimStart('.');
|
||||||
|
var blobPath = $"{companyId}/{templateId}/diagram.{ext}";
|
||||||
|
|
||||||
|
using var stream = diagramFile.OpenReadStream();
|
||||||
|
var (ok, err) = await _blobStorage.UploadAsync("formulatemplate-diagrams", blobPath, stream, diagramFile.ContentType);
|
||||||
|
if (!ok)
|
||||||
|
return Json(new { success = false, message = err });
|
||||||
|
|
||||||
|
entity.DiagramImagePath = blobPath;
|
||||||
|
entity.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, diagramImagePath = blobPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves a template diagram image from blob storage. The path is tenant-scoped
|
||||||
|
/// so cross-company access is prevented by checking CompanyId on the template.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> TemplateDiagram(int templateId)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
|
||||||
|
var entity = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||||
|
if (entity == null || entity.CompanyId != companyId || string.IsNullOrEmpty(entity.DiagramImagePath))
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var (ok, bytes, contentType, _) = await _blobStorage.DownloadAsync("formulatemplate-diagrams", entity.DiagramImagePath);
|
||||||
|
if (!ok || bytes == null || bytes.Length == 0) return NotFound();
|
||||||
|
return File(bytes, contentType ?? "image/jpeg");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Evaluates a NCalc formula with the supplied variable values.
|
||||||
|
/// Delegates to <see cref="ICustomFormulaAiService.EvaluateFormula"/> so NCalc stays
|
||||||
|
/// in the Application/Infrastructure layer.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public IActionResult EvaluateFormula([FromBody] EvaluateFormulaRequest req)
|
||||||
|
{
|
||||||
|
var result = _formulaAiService.EvaluateFormula(req);
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calls Claude to generate a formula template from a natural-language description
|
||||||
|
/// and an optional diagram image uploaded in the same multipart form.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> GenerateFormulaFromAi([FromForm] string description, IFormFile? diagramImage)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(description))
|
||||||
|
return Json(new { success = false, error = "Description is required." });
|
||||||
|
|
||||||
|
byte[]? imageBytes = null;
|
||||||
|
string? imageContentType = null;
|
||||||
|
if (diagramImage is { Length: > 0 })
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await diagramImage.CopyToAsync(ms);
|
||||||
|
imageBytes = ms.ToArray();
|
||||||
|
imageContentType = diagramImage.ContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _formulaAiService.GenerateFormulaAsync(
|
||||||
|
new GenerateFormulaFromAiRequest { Description = description },
|
||||||
|
imageBytes,
|
||||||
|
imageContentType);
|
||||||
|
|
||||||
|
return Json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates field variable names in a fieldsJson array against NCalc identifier rules:
|
||||||
|
/// must start with a letter, contain only letters/digits/underscores, and not use the
|
||||||
|
/// reserved name "rate" (which is auto-populated from the template's Default Rate).
|
||||||
|
/// Returns an error message string on failure, or null if all names are valid.
|
||||||
|
/// </summary>
|
||||||
|
private static string? ValidateTemplateFields(string? fieldsJson)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(fieldsJson)) return null;
|
||||||
|
|
||||||
|
List<System.Text.Json.JsonElement>? fields;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
fields = System.Text.Json.JsonSerializer.Deserialize<List<System.Text.Json.JsonElement>>(fieldsJson);
|
||||||
|
}
|
||||||
|
catch { return "Invalid fields JSON."; }
|
||||||
|
|
||||||
|
if (fields == null) return null;
|
||||||
|
|
||||||
|
var nameRegex = new System.Text.RegularExpressions.Regex(@"^[a-zA-Z][a-zA-Z0-9_]*$");
|
||||||
|
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var field in fields)
|
||||||
|
{
|
||||||
|
var name = field.TryGetProperty("name", out var nameProp) ? nameProp.GetString() ?? "" : "";
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
return "All fields must have a variable name.";
|
||||||
|
if (name == "rate")
|
||||||
|
return $"\"rate\" is a reserved variable name — it is pre-populated from the template's Default Rate.";
|
||||||
|
if (!nameRegex.IsMatch(name))
|
||||||
|
return $"Invalid field name \"{name}\": must start with a letter and contain only letters, digits, or underscores.";
|
||||||
|
if (!seen.Add(name))
|
||||||
|
return $"Duplicate field name \"{name}\".";
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
public record SaveTemplateJsonRequest(int Id, string? Subject, string? Body);
|
||||||
|
|||||||
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
|
|||||||
|
|
||||||
private async Task PopulateCustomersAsync(int? selectedId)
|
private async Task PopulateCustomersAsync(int? selectedId)
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = customers
|
ViewBag.Customers = customers
|
||||||
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
|
|||||||
@@ -342,14 +342,16 @@ public class DashboardController : Controller
|
|||||||
TipOfTheDay = data.TipOfTheDay
|
TipOfTheDay = data.TipOfTheDay
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Resolve company once so all remaining queries are explicitly scoped
|
||||||
|
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
var companyId = currentCompanyId ?? 0;
|
||||||
|
|
||||||
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
// Dropdowns for the "Add Custom Powder to Inventory" modal
|
||||||
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync())
|
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
|
||||||
.Where(c => c.IsActive)
|
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
.Select(c => new { c.Id, c.DisplayName })
|
.Select(c => new { c.Id, c.DisplayName })
|
||||||
.ToList();
|
.ToList();
|
||||||
var vendors = (await _unitOfWork.Vendors.GetAllAsync())
|
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
|
||||||
.Where(v => v.IsActive)
|
|
||||||
.OrderBy(v => v.CompanyName)
|
.OrderBy(v => v.CompanyName)
|
||||||
.Select(v => new { v.Id, v.CompanyName })
|
.Select(v => new { v.Id, v.CompanyName })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -357,7 +359,6 @@ public class DashboardController : Controller
|
|||||||
ViewBag.VendorList = vendors;
|
ViewBag.VendorList = vendors;
|
||||||
|
|
||||||
// Config health check — surface setup gaps to company admins
|
// Config health check — surface setup gaps to company admins
|
||||||
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
|
|
||||||
if (currentCompanyId.HasValue)
|
if (currentCompanyId.HasValue)
|
||||||
{
|
{
|
||||||
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value);
|
||||||
@@ -711,8 +712,8 @@ public class DashboardController : Controller
|
|||||||
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
||||||
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Check SKU uniqueness
|
// Check SKU uniqueness within this company
|
||||||
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
|
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId))
|
||||||
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
|
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
|
||||||
|
|
||||||
// Determine category display name for legacy field
|
// Determine category display name for legacy field
|
||||||
|
|||||||
@@ -125,5 +125,14 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
return View();
|
return View();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves the Custom Formula Item Templates help article explaining how to create NCalc formula
|
||||||
|
/// templates, use the AI generator, and add formula items to quotes and jobs.
|
||||||
|
/// </summary>
|
||||||
|
public IActionResult CustomFormulaTemplates()
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ public class InventoryController : Controller
|
|||||||
public async Task<IActionResult> Index(
|
public async Task<IActionResult> Index(
|
||||||
string? searchTerm,
|
string? searchTerm,
|
||||||
string? category,
|
string? category,
|
||||||
|
string? location,
|
||||||
string? sortColumn,
|
string? sortColumn,
|
||||||
string sortDirection = "asc",
|
string sortDirection = "asc",
|
||||||
bool lowStockOnly = false,
|
bool lowStockOnly = false,
|
||||||
@@ -87,50 +88,64 @@ public class InventoryController : Controller
|
|||||||
};
|
};
|
||||||
gridRequest.Validate();
|
gridRequest.Validate();
|
||||||
|
|
||||||
// Build search and category filter
|
// Build filter — compose search, category, location, and low-stock predicates
|
||||||
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
System.Linq.Expressions.Expression<Func<InventoryItem, bool>>? filter = null;
|
||||||
|
|
||||||
if (lowStockOnly && !string.IsNullOrWhiteSpace(searchTerm))
|
var hasSearch = !string.IsNullOrWhiteSpace(searchTerm);
|
||||||
{
|
var hasCategory = !string.IsNullOrWhiteSpace(category);
|
||||||
var search = searchTerm.ToLower();
|
var hasLocation = !string.IsNullOrWhiteSpace(location);
|
||||||
|
|
||||||
|
var search = searchTerm?.ToLower() ?? "";
|
||||||
|
var cat = category ?? "";
|
||||||
|
var loc = location ?? "";
|
||||||
|
|
||||||
|
if (lowStockOnly && hasSearch && hasLocation)
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||||
&& (i.SKU.ToLower().Contains(search)
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower())
|
||||||
|| i.Name.ToLower().Contains(search)
|
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
||||||
}
|
else if (lowStockOnly && hasSearch)
|
||||||
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||||
|
&& (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)));
|
||||||
|
else if (lowStockOnly && hasLocation)
|
||||||
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint
|
||||||
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
else if (lowStockOnly)
|
else if (lowStockOnly)
|
||||||
{
|
|
||||||
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
|
filter = i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint;
|
||||||
}
|
else if (hasSearch && hasCategory && hasLocation)
|
||||||
else if (!string.IsNullOrWhiteSpace(searchTerm) && !string.IsNullOrWhiteSpace(category))
|
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
{
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
// Both search and category filter
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
var search = searchTerm.ToLower();
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||||
var cat = category;
|
&& i.Category.ToLower() == cat.ToLower()
|
||||||
filter = i => (i.SKU.ToLower().Contains(search)
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
|| i.Name.ToLower().Contains(search)
|
else if (hasSearch && hasCategory)
|
||||||
|
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||||
&& i.Category.ToLower() == cat.ToLower();
|
&& i.Category.ToLower() == cat.ToLower();
|
||||||
}
|
else if (hasSearch && hasLocation)
|
||||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
filter = i => (i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
{
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
// Search only
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
var search = searchTerm.ToLower();
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search)))
|
||||||
filter = i => i.SKU.ToLower().Contains(search)
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
|| i.Name.ToLower().Contains(search)
|
else if (hasSearch)
|
||||||
|
filter = i => i.SKU.ToLower().Contains(search) || i.Name.ToLower().Contains(search)
|
||||||
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
|| (i.Description != null && i.Description.ToLower().Contains(search))
|
||||||
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
|| (i.ColorName != null && i.ColorName.ToLower().Contains(search))
|
||||||
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
|
|| (i.Manufacturer != null && i.Manufacturer.ToLower().Contains(search));
|
||||||
}
|
else if (hasCategory && hasLocation)
|
||||||
else if (!string.IsNullOrWhiteSpace(category))
|
filter = i => i.Category.ToLower() == cat.ToLower()
|
||||||
{
|
&& (i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
// Category filter only
|
else if (hasCategory)
|
||||||
var cat = category;
|
|
||||||
filter = i => i.Category.ToLower() == cat.ToLower();
|
filter = i => i.Category.ToLower() == cat.ToLower();
|
||||||
}
|
else if (hasLocation)
|
||||||
|
filter = i => i.Location != null && i.Location.ToLower() == loc.ToLower();
|
||||||
|
|
||||||
// Build orderBy function
|
// Build orderBy function
|
||||||
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
Func<IQueryable<InventoryItem>, IOrderedQueryable<InventoryItem>> orderBy = gridRequest.SortColumn switch
|
||||||
@@ -159,9 +174,11 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
|
||||||
|
|
||||||
// Load all items once to compute sidebar stats and category list in memory
|
// Load all items once to compute sidebar stats and dropdown option lists in memory
|
||||||
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
|
||||||
|
ViewBag.Locations = allItems.Select(i => i.Location).Where(l => !string.IsNullOrWhiteSpace(l)).Distinct().OrderBy(l => l).ToList();
|
||||||
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
|
||||||
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
|
||||||
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
|
||||||
@@ -169,6 +186,7 @@ public class InventoryController : Controller
|
|||||||
// Set ViewBag for sorting and filters
|
// Set ViewBag for sorting and filters
|
||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
ViewBag.Category = category;
|
ViewBag.Category = category;
|
||||||
|
ViewBag.Location = location;
|
||||||
ViewBag.LowStockOnly = lowStockOnly;
|
ViewBag.LowStockOnly = lowStockOnly;
|
||||||
ViewBag.SortColumn = gridRequest.SortColumn;
|
ViewBag.SortColumn = gridRequest.SortColumn;
|
||||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||||
@@ -183,6 +201,26 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a print-optimised list of all active inventory items in a given bin/location.
|
||||||
|
/// Renders without the site chrome (no layout) so the browser print dialog produces a
|
||||||
|
/// clean page. Items are sorted by name within the bin.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> PrintBin(string location)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(location))
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
|
var loc = location.Trim();
|
||||||
|
var items = await _unitOfWork.InventoryItems.FindAsync(
|
||||||
|
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
|
|
||||||
|
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
|
||||||
|
ViewBag.Location = loc;
|
||||||
|
ViewBag.PrintedAt = DateTime.Now;
|
||||||
|
return View(dtos);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the inventory item detail page. The primary vendor name is looked up
|
/// Renders the inventory item detail page. The primary vendor name is looked up
|
||||||
/// separately because the repository does not eager-load Vendor by default, avoiding
|
/// separately because the repository does not eager-load Vendor by default, avoiding
|
||||||
@@ -896,11 +934,32 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(qrUrl))
|
if (!string.IsNullOrWhiteSpace(qrUrl))
|
||||||
{
|
{
|
||||||
// QR path: fetch the product page; LookupByUrlAsync now maps all identity + spec fields
|
// QR path: try to extract product identity from the URL using manufacturer patterns.
|
||||||
|
// Prismatic Powders URLs embed both the SKU and color slug in the path
|
||||||
|
// (e.g. /shop/powder-coating-colors/PMB-6906/fire-red), so we can feed the full
|
||||||
|
// LookupAsync pipeline (Serper + direct URL + Claude) with real context instead of
|
||||||
|
// relying solely on a page fetch that may fail in production.
|
||||||
|
var activePatterns = (await _unitOfWork.ManufacturerLookupPatterns.GetAllAsync(ignoreQueryFilters: true))
|
||||||
|
.Where(p => p.IsActive).ToList();
|
||||||
|
var (urlMfr, urlColor, urlPart) = TryParseManufacturerUrl(qrUrl, activePatterns);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(urlMfr))
|
||||||
|
{
|
||||||
|
aiResult = await _aiLookupService.LookupAsync(urlMfr, urlColor, null, urlPart);
|
||||||
|
// The scanned QR URL is always the authoritative product page link — it came
|
||||||
|
// directly from the manufacturer's bag and is always fully-qualified. Overwrite
|
||||||
|
// whatever LookupAsync returned (which may be a scheme-less path from the template).
|
||||||
|
if (aiResult.Success)
|
||||||
|
aiResult.SpecPageUrl = qrUrl;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No pattern match — fall back to URL-based lookup
|
||||||
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
aiResult = await _aiLookupService.LookupByUrlAsync(qrUrl, null);
|
||||||
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
if (aiResult.Success && aiResult.SpecPageUrl == null)
|
||||||
aiResult.SpecPageUrl = qrUrl;
|
aiResult.SpecPageUrl = qrUrl;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(imageBase64))
|
else if (!string.IsNullOrWhiteSpace(imageBase64))
|
||||||
{
|
{
|
||||||
// Vision path: Claude reads what's printed on the label (limited to visible text)
|
// Vision path: Claude reads what's printed on the label (limited to visible text)
|
||||||
@@ -1106,7 +1165,8 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
// Build a set of SKUs already in this company's inventory so we can exclude them.
|
// Build a set of SKUs already in this company's inventory so we can exclude them.
|
||||||
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
|
// When editing, the current item's own SKU is re-included so its catalog entry still appears.
|
||||||
var existingItems = await _unitOfWork.InventoryItems.GetAllAsync();
|
var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId);
|
||||||
var existingSkus = existingItems
|
var existingSkus = existingItems
|
||||||
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
|
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
|
||||||
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
|
||||||
@@ -1182,7 +1242,7 @@ public class InventoryController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Find the default coating category to assign
|
// Find the default coating category to assign
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var coatingCategory = categories
|
var coatingCategory = categories
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
.Where(c => c.IsActive && c.IsCoating)
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
@@ -1270,6 +1330,72 @@ public class InventoryController : Controller
|
|||||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reverse-parses a scanned QR URL against known manufacturer URL templates to extract
|
||||||
|
/// product identity (manufacturer name, color name, part number). For example, a Prismatic
|
||||||
|
/// Powders URL like /shop/powder-coating-colors/PMB-6906/fire-red yields manufacturer
|
||||||
|
/// "Prismatic Powders", partNumber "PMB-6906", and colorName "Fire Red". Returns nulls
|
||||||
|
/// when no active pattern matches the URL domain.
|
||||||
|
/// </summary>
|
||||||
|
private static (string? manufacturer, string? colorName, string? partNumber) TryParseManufacturerUrl(
|
||||||
|
string url, IEnumerable<Core.Entities.ManufacturerLookupPattern> patterns)
|
||||||
|
{
|
||||||
|
Uri? parsed;
|
||||||
|
try { parsed = new Uri(url); } catch { return (null, null, null); }
|
||||||
|
|
||||||
|
var host = parsed.Host.Replace("www.", "", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var pattern in patterns.Where(p => !string.IsNullOrEmpty(p.Domain) && !string.IsNullOrEmpty(p.ProductUrlTemplate)))
|
||||||
|
{
|
||||||
|
if (!host.Equals(pattern.Domain, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var template = pattern.ProductUrlTemplate!;
|
||||||
|
var placeholderKeys = new[] { "{partNumber}", "{slug}", "{colorName}", "{colorCode}" };
|
||||||
|
|
||||||
|
// Find the first placeholder to split off the static prefix
|
||||||
|
var firstIdx = placeholderKeys
|
||||||
|
.Select(ph => template.IndexOf(ph, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Where(i => i >= 0)
|
||||||
|
.DefaultIfEmpty(-1)
|
||||||
|
.Min();
|
||||||
|
|
||||||
|
if (firstIdx < 0) continue;
|
||||||
|
|
||||||
|
var staticPrefix = template[..firstIdx].TrimEnd('/');
|
||||||
|
var fullUrl = url.TrimEnd('/');
|
||||||
|
if (!fullUrl.StartsWith(staticPrefix, StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var templateSegments = template[firstIdx..].Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var urlSegments = fullUrl[staticPrefix.Length..].Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
|
var extracted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
bool matched = true;
|
||||||
|
for (int i = 0; i < templateSegments.Length && i < urlSegments.Length; i++)
|
||||||
|
{
|
||||||
|
var seg = templateSegments[i];
|
||||||
|
if (seg.StartsWith("{") && seg.EndsWith("}"))
|
||||||
|
extracted[seg[1..^1]] = urlSegments[i];
|
||||||
|
else if (!seg.Equals(urlSegments[i], StringComparison.OrdinalIgnoreCase))
|
||||||
|
{ matched = false; break; }
|
||||||
|
}
|
||||||
|
if (!matched) continue;
|
||||||
|
|
||||||
|
extracted.TryGetValue("partNumber", out var partNumber);
|
||||||
|
var slug = extracted.TryGetValue("slug", out var s) ? s
|
||||||
|
: extracted.TryGetValue("colorName", out var cn) ? cn
|
||||||
|
: extracted.TryGetValue("colorCode", out var cc) ? cc : null;
|
||||||
|
|
||||||
|
// Convert URL slug to display name: "fire-red" → "Fire Red"
|
||||||
|
string? colorName = slug == null ? null
|
||||||
|
: string.Join(" ", slug.Split(new[] { '-', '_' }, StringSplitOptions.RemoveEmptyEntries)
|
||||||
|
.Select(w => w.Length > 0 ? char.ToUpperInvariant(w[0]) + w[1..].ToLowerInvariant() : w));
|
||||||
|
|
||||||
|
return (pattern.ManufacturerName, colorName, partNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
|
/// Normalizes a string to title-case using the current culture's TextInfo. Applied to
|
||||||
/// inventory item names on create and edit so the list view is consistently formatted
|
/// inventory item names on create and edit so the list view is consistently formatted
|
||||||
@@ -1369,11 +1495,23 @@ public class InventoryController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
|
||||||
ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
|
.OrderBy(v => v.CompanyName).ToList();
|
||||||
|
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
|
||||||
|
|
||||||
|
// Build {categoryId: [vendorId, ...]} so the inventory form can filter vendors by category
|
||||||
|
var categoryVendorMap = new Dictionary<string, List<int>>();
|
||||||
|
foreach (var v in vendors)
|
||||||
|
foreach (var cat in v.Categories)
|
||||||
|
{
|
||||||
|
var key = cat.Id.ToString();
|
||||||
|
if (!categoryVendorMap.ContainsKey(key)) categoryVendorMap[key] = new List<int>();
|
||||||
|
categoryVendorMap[key].Add(v.Id);
|
||||||
|
}
|
||||||
|
ViewBag.CategoryVendorMapJson = System.Text.Json.JsonSerializer.Serialize(categoryVendorMap);
|
||||||
|
|
||||||
// Load categories from lookup table
|
// Load categories from lookup table
|
||||||
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
var categories = allCategories
|
var categories = allCategories
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive)
|
||||||
.OrderBy(c => c.DisplayOrder)
|
.OrderBy(c => c.DisplayOrder)
|
||||||
@@ -1497,11 +1635,12 @@ public class InventoryController : Controller
|
|||||||
/// Renders a print-optimised label for the inventory item containing the QR code,
|
/// Renders a print-optimised label for the inventory item containing the QR code,
|
||||||
/// item name, SKU, and colour. Designed to be printed directly from the browser.
|
/// item name, SKU, and colour. Designed to be printed directly from the browser.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Label(int? id)
|
public async Task<IActionResult> Label(int? id, bool embed = false)
|
||||||
{
|
{
|
||||||
if (id == null) return NotFound();
|
if (id == null) return NotFound();
|
||||||
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
|
var item = await _unitOfWork.InventoryItems.GetByIdAsync(id.Value);
|
||||||
if (item == null) return NotFound();
|
if (item == null) return NotFound();
|
||||||
|
ViewBag.IsEmbed = embed;
|
||||||
return View(_mapper.Map<InventoryItemDto>(item));
|
return View(_mapper.Map<InventoryItemDto>(item));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1738,7 +1877,8 @@ public class InventoryController : Controller
|
|||||||
DateTime? dateTo,
|
DateTime? dateTo,
|
||||||
string? typeFilter)
|
string? typeFilter)
|
||||||
{
|
{
|
||||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync();
|
var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId);
|
||||||
var itemList = allItems
|
var itemList = allItems
|
||||||
.Where(i => i.IsActive || i.QuantityOnHand > 0)
|
.Where(i => i.IsActive || i.QuantityOnHand > 0)
|
||||||
.OrderBy(i => i.Name)
|
.OrderBy(i => i.Name)
|
||||||
|
|||||||
@@ -355,6 +355,15 @@ public class InvoicesController : Controller
|
|||||||
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value, false, j => j.Customer, j => j.JobItems);
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
|
// Pre-load coats so we can derive color names for invoice line items
|
||||||
|
var activeItemIds = job.JobItems.Where(ji => !ji.IsDeleted).Select(ji => ji.Id).ToList();
|
||||||
|
var allCoats = activeItemIds.Any()
|
||||||
|
? (await _unitOfWork.JobItemCoats.FindAsync(c => activeItemIds.Contains(c.JobItemId) && !c.IsDeleted)).ToList()
|
||||||
|
: new List<JobItemCoat>();
|
||||||
|
var coatsByItem = allCoats
|
||||||
|
.GroupBy(c => c.JobItemId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.OrderBy(c => c.Sequence).ToList());
|
||||||
|
|
||||||
// Validate no existing active invoice for this job (voided ones are kept as history)
|
// Validate no existing active invoice for this job (voided ones are kept as history)
|
||||||
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
var existing = await _unitOfWork.Invoices.GetForJobAsync(jobId.Value);
|
||||||
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
if (existing != null && existing.Status != InvoiceStatus.Voided)
|
||||||
@@ -404,6 +413,16 @@ public class InvoicesController : Controller
|
|||||||
revenueAccountId = ci.RevenueAccountId;
|
revenueAccountId = ci.RevenueAccountId;
|
||||||
revenueAccountId ??= defaultRevenueAccount?.Id;
|
revenueAccountId ??= defaultRevenueAccount?.Id;
|
||||||
|
|
||||||
|
// Derive color from coats when the item itself has no explicit color set
|
||||||
|
var derivedColor = item.ColorName;
|
||||||
|
if (string.IsNullOrEmpty(derivedColor) && coatsByItem.TryGetValue(item.Id, out var itemCoats))
|
||||||
|
{
|
||||||
|
var coatColors = itemCoats
|
||||||
|
.Where(c => !string.IsNullOrEmpty(c.ColorName))
|
||||||
|
.Select(c => c.ColorName!);
|
||||||
|
derivedColor = string.Join(" / ", coatColors);
|
||||||
|
}
|
||||||
|
|
||||||
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
dto.InvoiceItems.Add(new CreateInvoiceItemDto
|
||||||
{
|
{
|
||||||
SourceJobItemId = item.Id,
|
SourceJobItemId = item.Id,
|
||||||
@@ -412,7 +431,7 @@ public class InvoicesController : Controller
|
|||||||
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
Quantity = item.Quantity > 0 ? item.Quantity : 1,
|
||||||
UnitPrice = item.UnitPrice,
|
UnitPrice = item.UnitPrice,
|
||||||
TotalPrice = item.TotalPrice,
|
TotalPrice = item.TotalPrice,
|
||||||
ColorName = item.ColorName,
|
ColorName = derivedColor,
|
||||||
Notes = item.Notes,
|
Notes = item.Notes,
|
||||||
DisplayOrder = order++,
|
DisplayOrder = order++,
|
||||||
RevenueAccountId = revenueAccountId
|
RevenueAccountId = revenueAccountId
|
||||||
@@ -445,16 +464,22 @@ public class InvoicesController : Controller
|
|||||||
// If the job came from a quote, carry over the quote-level costs and agreed terms.
|
// If the job came from a quote, carry over the quote-level costs and agreed terms.
|
||||||
// The quote SubTotal = sum(items) + oven batch cost + shop supplies.
|
// The quote SubTotal = sum(items) + oven batch cost + shop supplies.
|
||||||
// Job items only capture per-item prices, so oven & shop supplies need a separate line.
|
// Job items only capture per-item prices, so oven & shop supplies need a separate line.
|
||||||
// Read directly from the quote snapshot — never try to reverse-engineer from job.FinalPrice
|
// For fee components, prefer the job's own breakdown snapshot (updated every time the job
|
||||||
// because FinalPrice is recalculated on every item edit and can drift from the original quote.
|
// is saved) over the source quote — the quote's FacilityOverheadCost was only added in
|
||||||
|
// migration AddQuotePricingSnapshotFields (May 2026); older quotes have 0 there even though
|
||||||
|
// overhead was included in the quote total. Tax and discount still come from the quote
|
||||||
|
// because those represent the customer-approved agreed terms.
|
||||||
if (sourceQuote != null)
|
if (sourceQuote != null)
|
||||||
{
|
{
|
||||||
// Bundle all quote-level charges so the invoice subtotal matches the quote total.
|
// Prefer job breakdown values for dynamic fee components; fall back to quote for
|
||||||
// FacilityOverheadCost is included — it is a real cost baked into the quoted price.
|
// compatibility with jobs that were never re-saved after the May 2026 migration.
|
||||||
var processingFees = sourceQuote.OvenBatchCost
|
var ovenCost = jobBreakdown != null ? jobBreakdown.OvenBatchCost : sourceQuote.OvenBatchCost;
|
||||||
+ sourceQuote.FacilityOverheadCost
|
var overhead = jobBreakdown != null ? jobBreakdown.FacilityOverheadCost : sourceQuote.FacilityOverheadCost;
|
||||||
+ sourceQuote.ShopSuppliesAmount
|
var shopSupplies = jobBreakdown != null ? jobBreakdown.ShopSuppliesAmount : sourceQuote.ShopSuppliesAmount;
|
||||||
+ sourceQuote.RushFee;
|
var rushFee = jobBreakdown != null ? jobBreakdown.RushFee : sourceQuote.RushFee;
|
||||||
|
|
||||||
|
// Bundle all quote-level charges so the invoice subtotal matches the job total.
|
||||||
|
var processingFees = ovenCost + overhead + shopSupplies + rushFee;
|
||||||
|
|
||||||
if (processingFees > 0.01m)
|
if (processingFees > 0.01m)
|
||||||
{
|
{
|
||||||
@@ -1461,6 +1486,7 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var totalCreditCreated = 0m; // populated inside transaction, used in success message
|
||||||
|
|
||||||
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
{
|
{
|
||||||
@@ -1480,6 +1506,75 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
await _unitOfWork.Payments.SoftDeleteAsync(payment.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-release any deposits that were applied to this invoice so they can be
|
||||||
|
// auto-applied to the replacement invoice. Without this, AppliedToInvoiceId
|
||||||
|
// stays set and the deposit lookup (AppliedToInvoiceId == null) skips them.
|
||||||
|
var appliedDeposits = await _unitOfWork.Deposits.FindAsync(
|
||||||
|
d => d.AppliedToInvoiceId == invoice.Id && !d.IsDeleted);
|
||||||
|
var totalDepositReleased = 0m;
|
||||||
|
foreach (var deposit in appliedDeposits)
|
||||||
|
{
|
||||||
|
deposit.AppliedToInvoiceId = null;
|
||||||
|
deposit.AppliedDate = null;
|
||||||
|
deposit.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.Deposits.UpdateAsync(deposit);
|
||||||
|
totalDepositReleased += deposit.Amount;
|
||||||
|
}
|
||||||
|
// Restore the CustomerDeposits 2300 liability that was cleared when the deposits
|
||||||
|
// were applied. Mirrors the DR at apply time; follows the same simplified reversal
|
||||||
|
// pattern as the rest of the void (regular payment GL entries are also left as-is).
|
||||||
|
if (totalDepositReleased > 0)
|
||||||
|
{
|
||||||
|
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||||
|
await _accountBalanceService.CreditAsync(custDepositsAcctId, totalDepositReleased);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert non-deposit payments (cash, card, check, online) to customer credits so
|
||||||
|
// the money isn't lost when the invoice is voided. Each payment becomes a CRED-
|
||||||
|
// Deposit record linked to the same job; it will auto-apply when the replacement
|
||||||
|
// invoice is created, exactly like a normal deposit.
|
||||||
|
var nonDepositPayments = invoice.Payments
|
||||||
|
.Where(p => !p.IsDeleted && !(p.Reference ?? "").StartsWith("Deposit "))
|
||||||
|
.ToList();
|
||||||
|
if (nonDepositPayments.Any())
|
||||||
|
{
|
||||||
|
var credPrefix = $"CRED-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
|
||||||
|
var existingNums = (await _unitOfWork.Deposits.FindAsync(
|
||||||
|
d => d.CompanyId == invoice.CompanyId && d.ReceiptNumber.StartsWith(credPrefix),
|
||||||
|
ignoreQueryFilters: true))
|
||||||
|
.Select(d => d.ReceiptNumber).ToList();
|
||||||
|
var maxNum = 0;
|
||||||
|
foreach (var rn in existingNums)
|
||||||
|
{
|
||||||
|
var suffix = rn.Length >= credPrefix.Length + 4 ? rn[credPrefix.Length..] : "";
|
||||||
|
if (int.TryParse(suffix, out int parsed) && parsed > maxNum) maxNum = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var creditCustDepositsAcctId = await GetCustomerDepositsAccountIdAsync(invoice.CompanyId);
|
||||||
|
foreach (var payment in nonDepositPayments)
|
||||||
|
{
|
||||||
|
maxNum++;
|
||||||
|
await _unitOfWork.Deposits.AddAsync(new Core.Entities.Deposit
|
||||||
|
{
|
||||||
|
CompanyId = invoice.CompanyId,
|
||||||
|
CustomerId = invoice.CustomerId,
|
||||||
|
JobId = invoice.JobId,
|
||||||
|
Amount = payment.Amount,
|
||||||
|
PaymentMethod = payment.PaymentMethod,
|
||||||
|
ReceivedDate = payment.PaymentDate,
|
||||||
|
Reference = payment.Reference,
|
||||||
|
Notes = $"Credit from voided invoice {invoice.InvoiceNumber}" +
|
||||||
|
(string.IsNullOrWhiteSpace(payment.Notes) ? "." : $". Original: {payment.Notes}"),
|
||||||
|
ReceiptNumber = $"{credPrefix}{maxNum:D4}",
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
});
|
||||||
|
totalCreditCreated += payment.Amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CR CustomerDeposits to create the liability matching the cash already in Checking
|
||||||
|
await _accountBalanceService.CreditAsync(creditCustDepositsAcctId, totalCreditCreated);
|
||||||
|
}
|
||||||
|
|
||||||
// Void any gift certificates that were generated from this invoice.
|
// Void any gift certificates that were generated from this invoice.
|
||||||
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
// Capture each GC's remaining balance BEFORE voiding so the GL entries below can use it.
|
||||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(invoice.CompanyId);
|
||||||
@@ -1531,7 +1626,10 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
}); // end ExecuteInTransactionAsync
|
}); // end ExecuteInTransactionAsync
|
||||||
|
|
||||||
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.";
|
var creditMsg = totalCreditCreated > 0
|
||||||
|
? $" {totalCreditCreated:C} converted to customer credit and will auto-apply to the next invoice."
|
||||||
|
: "";
|
||||||
|
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} has been voided.{creditMsg}";
|
||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -1690,6 +1788,59 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET: /Invoices/DownloadPackingSlip/5
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a no-price packing slip PDF for physical pickup/delivery paperwork.
|
||||||
|
/// Reuses the same company branding and invoice data pipeline as DownloadPdf but
|
||||||
|
/// delegates to GeneratePackingSlipPdfAsync which omits all pricing columns.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<IActionResult> DownloadPackingSlip(int? id, bool inline = false)
|
||||||
|
{
|
||||||
|
if (id == null) return NotFound();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var invoice = await LoadInvoiceForViewAsync(id.Value);
|
||||||
|
if (invoice == null) return NotFound();
|
||||||
|
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
|
||||||
|
var companyInfo = new Application.DTOs.Company.CompanyInfoDto
|
||||||
|
{
|
||||||
|
CompanyName = company?.CompanyName ?? string.Empty,
|
||||||
|
Phone = company?.Phone,
|
||||||
|
Address = company?.Address,
|
||||||
|
City = company?.City,
|
||||||
|
State = company?.State,
|
||||||
|
ZipCode = company?.ZipCode,
|
||||||
|
PrimaryContactEmail = company?.PrimaryContactEmail
|
||||||
|
};
|
||||||
|
|
||||||
|
var (logoData, logoContentType) = await LoadCompanyLogoAsync(company);
|
||||||
|
var dto = await BuildInvoiceDtoAsync(invoice);
|
||||||
|
var pdfBytes = await _pdfService.GeneratePackingSlipPdfAsync(dto, logoData, logoContentType, companyInfo);
|
||||||
|
var fileName = $"PackingSlip-{invoice.InvoiceNumber}.pdf";
|
||||||
|
|
||||||
|
if (inline)
|
||||||
|
{
|
||||||
|
Response.Headers["Content-Disposition"] = $"inline; filename=\"{fileName}\"";
|
||||||
|
return File(pdfBytes, "application/pdf");
|
||||||
|
}
|
||||||
|
|
||||||
|
return File(pdfBytes, "application/pdf", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error generating packing slip for invoice {Id}", id);
|
||||||
|
TempData["ErrorPermanent"] = $"Packing slip generation failed: {ex.Message}";
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// GET: /Invoices/ForJob/5 — redirect to existing or Create
|
// GET: /Invoices/ForJob/5 — redirect to existing or Create
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -2213,7 +2364,7 @@ public class InvoicesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
|
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null)
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
|
ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList();
|
||||||
|
|
||||||
// Expose company default tax rate and exempt customer IDs for client-side tax handling
|
// Expose company default tax rate and exempt customer IDs for client-side tax handling
|
||||||
@@ -3016,6 +3167,50 @@ public class InvoicesController : Controller
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single invoice line item.
|
||||||
|
/// Blocked on paid/voided invoices (same gate as the full Edit action).
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchInvoiceItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.InvoiceItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var invoice = await _unitOfWork.Invoices.GetByIdAsync(item.InvoiceId);
|
||||||
|
if (invoice == null || invoice.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
if (invoice.Status is not (InvoiceStatus.Draft or InvoiceStatus.Sent or InvoiceStatus.Overdue))
|
||||||
|
return BadRequest(new { error = "Cannot edit items on a paid or voided invoice." });
|
||||||
|
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.InvoiceItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var allItems = await _unitOfWork.InvoiceItems.FindAsync(ii => ii.InvoiceId == invoice.Id);
|
||||||
|
var newSubTotal = allItems.Sum(i => i.TotalPrice);
|
||||||
|
invoice.SubTotal = newSubTotal;
|
||||||
|
invoice.TaxAmount = Math.Round(newSubTotal * invoice.TaxPercent / 100m, 2);
|
||||||
|
invoice.Total = Math.Round(newSubTotal - invoice.DiscountAmount + invoice.TaxAmount, 2);
|
||||||
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
subtotal = invoice.SubTotal,
|
||||||
|
taxAmount = invoice.TaxAmount,
|
||||||
|
total = invoice.Total,
|
||||||
|
balanceDue = invoice.BalanceDue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns logo bytes and content type for PDF generation.
|
/// Returns logo bytes and content type for PDF generation.
|
||||||
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
/// Prefers blob-stored logos (LogoFilePath) over the legacy DB column (LogoData).
|
||||||
@@ -3031,3 +3226,11 @@ public class InvoicesController : Controller
|
|||||||
return (company.LogoData, company.LogoContentType);
|
return (company.LogoData, company.LogoContentType);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PatchInvoiceItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,7 +36,9 @@ public class JobTemplatesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var templates = await _unitOfWork.JobTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId,
|
||||||
false,
|
false,
|
||||||
t => t.Customer,
|
t => t.Customer,
|
||||||
t => t.Items);
|
t => t.Items);
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// Default landing view: On Floor — redirect bare /Jobs to ?statusGroup=active
|
||||||
|
// so completed/cancelled jobs don't clutter the first screen.
|
||||||
|
if (string.IsNullOrEmpty(statusGroup) && string.IsNullOrEmpty(searchTerm) && string.IsNullOrEmpty(tagFilter))
|
||||||
|
return RedirectToAction("Index", new { statusGroup = "active" });
|
||||||
|
|
||||||
// Create and validate grid request
|
// Create and validate grid request
|
||||||
var gridRequest = new GridRequest
|
var gridRequest = new GridRequest
|
||||||
{
|
{
|
||||||
@@ -141,6 +146,13 @@ public class JobsController : Controller
|
|||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled;
|
||||||
}
|
}
|
||||||
|
else if (statusGroup == "completed")
|
||||||
|
{
|
||||||
|
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
|
||||||
|
}
|
||||||
|
// "all" or unknown group: no filter applied (show every status)
|
||||||
}
|
}
|
||||||
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
else if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
@@ -195,6 +207,27 @@ public class JobsController : Controller
|
|||||||
gridRequest, jobDtos,
|
gridRequest, jobDtos,
|
||||||
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
string.IsNullOrWhiteSpace(tagFilter) ? totalCount : jobDtos.Count);
|
||||||
|
|
||||||
|
// Pill badge counts — always global (not scoped to current filter/page)
|
||||||
|
var today = DateTime.Today;
|
||||||
|
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
|
||||||
|
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
|
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.DueDate < today
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
|
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
|
||||||
|
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
|
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||||
|
|
||||||
// Set ViewBag for sorting
|
// Set ViewBag for sorting
|
||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
ViewBag.StatusGroup = statusGroup;
|
ViewBag.StatusGroup = statusGroup;
|
||||||
@@ -459,6 +492,9 @@ public class JobsController : Controller
|
|||||||
isLaborItem = ji.IsLaborItem,
|
isLaborItem = ji.IsLaborItem,
|
||||||
isSalesItem = ji.IsSalesItem,
|
isSalesItem = ji.IsSalesItem,
|
||||||
isAiItem = ji.IsAiItem,
|
isAiItem = ji.IsAiItem,
|
||||||
|
isCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
customItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
formulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
sku = ji.Sku,
|
sku = ji.Sku,
|
||||||
requiresSandblasting = ji.RequiresSandblasting,
|
requiresSandblasting = ji.RequiresSandblasting,
|
||||||
requiresMasking = ji.RequiresMasking,
|
requiresMasking = ji.RequiresMasking,
|
||||||
@@ -477,6 +513,7 @@ public class JobsController : Controller
|
|||||||
transferEfficiency = c.TransferEfficiency,
|
transferEfficiency = c.TransferEfficiency,
|
||||||
powderCostPerLb = c.PowderCostPerLb,
|
powderCostPerLb = c.PowderCostPerLb,
|
||||||
powderToOrder = c.PowderToOrder,
|
powderToOrder = c.PowderToOrder,
|
||||||
|
noExtraLayerCharge = c.NoExtraLayerCharge,
|
||||||
notes = c.Notes
|
notes = c.Notes
|
||||||
}),
|
}),
|
||||||
prepServices = ji.PrepServices.Select(ps => new {
|
prepServices = ji.PrepServices.Select(ps => new {
|
||||||
@@ -499,11 +536,21 @@ public class JobsController : Controller
|
|||||||
ViewBag.MaterialsUsed = allJobTransactions;
|
ViewBag.MaterialsUsed = allJobTransactions;
|
||||||
|
|
||||||
// Inventory items for the manual log-material modal
|
// Inventory items for the manual log-material modal
|
||||||
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync())
|
var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId))
|
||||||
.OrderBy(i => i.Name)
|
.OrderBy(i => i.Name)
|
||||||
.Select(i => new { i.Id, i.Name, i.UnitOfMeasure, i.QuantityOnHand })
|
.Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
|
||||||
.ToList();
|
.ToList();
|
||||||
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal);
|
var jsonOpts = new System.Text.Json.JsonSerializerOptions { PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase };
|
||||||
|
ViewBag.InventoryItemsForModal = System.Text.Json.JsonSerializer.Serialize(inventoryItemsForModal, jsonOpts);
|
||||||
|
|
||||||
|
// IDs of powders already assigned to this job's coats — shown at top of log-material dropdown
|
||||||
|
var jobPowderIds = (jobDto.Items ?? new List<PowderCoating.Application.DTOs.Job.JobItemDto>())
|
||||||
|
.SelectMany(i => i.Coats ?? new List<PowderCoating.Application.DTOs.Job.JobItemCoatDto>())
|
||||||
|
.Where(c => c.InventoryItemId.HasValue)
|
||||||
|
.Select(c => c.InventoryItemId!.Value)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
ViewBag.JobPowderIds = System.Text.Json.JsonSerializer.Serialize(jobPowderIds, jsonOpts);
|
||||||
|
|
||||||
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
// Pre-logged powder grouped by InventoryItemId (for Complete Job modal pre-fill)
|
||||||
ViewBag.PreLoggedPowder = allJobTransactions
|
ViewBag.PreLoggedPowder = allJobTransactions
|
||||||
@@ -518,7 +565,7 @@ public class JobsController : Controller
|
|||||||
ViewBag.JobPhotoMax = photoMax;
|
ViewBag.JobPhotoMax = photoMax;
|
||||||
|
|
||||||
// Customer list for inline customer-change dropdown
|
// Customer list for inline customer-change dropdown
|
||||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == job.CompanyId);
|
||||||
ViewBag.CustomerSelectList = allCustomers
|
ViewBag.CustomerSelectList = allCustomers
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive)
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
@@ -624,7 +671,8 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync())
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId))
|
||||||
.OrderBy(s => s.DisplayOrder).ToList();
|
.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
|
|
||||||
ViewBag.AllStatuses = allStatuses;
|
ViewBag.AllStatuses = allStatuses;
|
||||||
@@ -647,7 +695,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList();
|
var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId)).ToList();
|
||||||
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
|
var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
|
||||||
if (newStatus == null) return BadRequest("Invalid status.");
|
if (newStatus == null) return BadRequest("Invalid status.");
|
||||||
|
|
||||||
@@ -835,7 +883,7 @@ public class JobsController : Controller
|
|||||||
// Optionally advance status to In Preparation
|
// Optionally advance status to In Preparation
|
||||||
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
|
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation)
|
||||||
{
|
{
|
||||||
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == jobToUpdate.CompanyId);
|
||||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
||||||
if (inPrepStatus != null)
|
if (inPrepStatus != null)
|
||||||
{
|
{
|
||||||
@@ -892,7 +940,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
|
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus)
|
||||||
{
|
{
|
||||||
var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId);
|
||||||
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
|
||||||
if (inPrepStatus != null)
|
if (inPrepStatus != null)
|
||||||
{
|
{
|
||||||
@@ -1067,6 +1115,8 @@ public class JobsController : Controller
|
|||||||
QuoteId = dto.QuoteId,
|
QuoteId = dto.QuoteId,
|
||||||
AssignedUserId = dto.AssignedUserId,
|
AssignedUserId = dto.AssignedUserId,
|
||||||
OvenCostId = dto.OvenCostId,
|
OvenCostId = dto.OvenCostId,
|
||||||
|
OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = dto.OvenCycleMinutes,
|
||||||
Description = dto.Description,
|
Description = dto.Description,
|
||||||
JobPriorityId = dto.JobPriorityId,
|
JobPriorityId = dto.JobPriorityId,
|
||||||
JobStatusId = pendingStatus?.Id ?? 1,
|
JobStatusId = pendingStatus?.Id ?? 1,
|
||||||
@@ -1138,7 +1188,7 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, createCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, createOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
||||||
|
|
||||||
job.FinalPrice = totals.Total;
|
job.FinalPrice = totals.Total;
|
||||||
job.OvenBatchCost = totals.OvenBatchCost;
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
@@ -1206,6 +1256,9 @@ public class JobsController : Controller
|
|||||||
CustomerId = job.CustomerId,
|
CustomerId = job.CustomerId,
|
||||||
QuoteId = job.QuoteId,
|
QuoteId = job.QuoteId,
|
||||||
AssignedUserId = job.AssignedUserId,
|
AssignedUserId = job.AssignedUserId,
|
||||||
|
OvenCostId = job.OvenCostId,
|
||||||
|
OvenBatches = job.OvenBatches > 0 ? job.OvenBatches : 1,
|
||||||
|
OvenCycleMinutes = job.OvenCycleMinutes,
|
||||||
Description = job.Description,
|
Description = job.Description,
|
||||||
JobStatusId = job.JobStatusId,
|
JobStatusId = job.JobStatusId,
|
||||||
JobPriorityId = job.JobPriorityId,
|
JobPriorityId = job.JobPriorityId,
|
||||||
@@ -1232,6 +1285,9 @@ public class JobsController : Controller
|
|||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
IsAiItem = ji.IsAiItem,
|
IsAiItem = ji.IsAiItem,
|
||||||
|
IsCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -1250,6 +1306,7 @@ public class JobsController : Controller
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge,
|
||||||
Notes = c.Notes
|
Notes = c.Notes
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||||
@@ -1380,6 +1437,9 @@ public class JobsController : Controller
|
|||||||
job.CustomerId = dto.CustomerId;
|
job.CustomerId = dto.CustomerId;
|
||||||
job.QuoteId = dto.QuoteId;
|
job.QuoteId = dto.QuoteId;
|
||||||
job.Description = dto.Description;
|
job.Description = dto.Description;
|
||||||
|
job.OvenCostId = dto.OvenCostId;
|
||||||
|
job.OvenBatches = dto.OvenBatches > 0 ? dto.OvenBatches : 1;
|
||||||
|
job.OvenCycleMinutes = dto.OvenCycleMinutes;
|
||||||
await RecordStatusChangeAsync(job, dto.JobStatusId);
|
await RecordStatusChangeAsync(job, dto.JobStatusId);
|
||||||
job.JobStatusId = dto.JobStatusId;
|
job.JobStatusId = dto.JobStatusId;
|
||||||
job.JobPriorityId = dto.JobPriorityId;
|
job.JobPriorityId = dto.JobPriorityId;
|
||||||
@@ -1606,7 +1666,7 @@ public class JobsController : Controller
|
|||||||
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
var totals = await _pricingService.CalculateQuoteTotalsAsync(
|
||||||
dto.JobItems, companyId, dto.CustomerId,
|
dto.JobItems, companyId, dto.CustomerId,
|
||||||
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
await GetEffectiveTaxPercentAsync(dto.CustomerId, editCosts?.TaxPercent ?? 0m),
|
||||||
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, job.OvenBatches, job.OvenCycleMinutes);
|
dto.DiscountType, dto.DiscountValue, dto.IsRushJob, editOvenRate, dto.OvenBatches > 0 ? dto.OvenBatches : 1, dto.OvenCycleMinutes);
|
||||||
job.FinalPrice = totals.Total;
|
job.FinalPrice = totals.Total;
|
||||||
job.OvenBatchCost = totals.OvenBatchCost;
|
job.OvenBatchCost = totals.OvenBatchCost;
|
||||||
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
job.ShopSuppliesAmount = totals.ShopSuppliesAmount;
|
||||||
@@ -1798,8 +1858,27 @@ public class JobsController : Controller
|
|||||||
{
|
{
|
||||||
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
|
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
|
||||||
|
|
||||||
|
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId && t.IsActive);
|
||||||
|
ViewBag.CustomFormulaTemplates = formulaTemplates
|
||||||
|
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
id = t.Id,
|
||||||
|
name = t.Name,
|
||||||
|
description = t.Description,
|
||||||
|
outputMode = t.OutputMode,
|
||||||
|
fieldsJson = t.FieldsJson,
|
||||||
|
formula = t.Formula,
|
||||||
|
defaultRate = t.DefaultRate,
|
||||||
|
rateLabel = t.RateLabel,
|
||||||
|
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
|
||||||
|
? (string?)null
|
||||||
|
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
await PopulateDropdowns();
|
await PopulateDropdowns();
|
||||||
await PopulatePrepServicesAsync();
|
await PopulatePrepServicesAsync(companyId);
|
||||||
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
|
||||||
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
|
||||||
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
|
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
|
||||||
@@ -1807,6 +1886,7 @@ public class JobsController : Controller
|
|||||||
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
ViewBag.ComplexityModeratePercent = costs?.ComplexityModeratePercent ?? 5m;
|
||||||
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
ViewBag.ComplexityComplexPercent = costs?.ComplexityComplexPercent ?? 15m;
|
||||||
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
ViewBag.ComplexityExtremePercent = costs?.ComplexityExtremePercent ?? 25m;
|
||||||
|
ViewBag.DefaultOvenCycleMinutes = costs?.DefaultOvenCycleMinutes ?? 45;
|
||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.UseMetric = useMetric;
|
ViewBag.UseMetric = useMetric;
|
||||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||||
@@ -1819,7 +1899,9 @@ public class JobsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateDropdowns()
|
private async Task PopulateDropdowns()
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = new SelectList(
|
ViewBag.Customers = new SelectList(
|
||||||
customers.Where(c => c.IsActive).Select(c => new
|
customers.Where(c => c.IsActive).Select(c => new
|
||||||
{
|
{
|
||||||
@@ -1830,8 +1912,6 @@ public class JobsController : Controller
|
|||||||
}).OrderBy(c => c.DisplayName),
|
}).OrderBy(c => c.DisplayName),
|
||||||
"Id",
|
"Id",
|
||||||
"DisplayName");
|
"DisplayName");
|
||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var users = await _userManager.Users
|
var users = await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
@@ -2213,13 +2293,13 @@ public class JobsController : Controller
|
|||||||
/// Loads all active prep services into ViewBag for the item wizard's prep services step.
|
/// Loads all active prep services into ViewBag for the item wizard's prep services step.
|
||||||
/// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
|
/// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulatePrepServicesAsync()
|
private async Task PopulatePrepServicesAsync(int companyId)
|
||||||
{
|
{
|
||||||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
|
||||||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||||||
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
|
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count());
|
||||||
|
|
||||||
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
|
||||||
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
||||||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -2352,6 +2432,28 @@ public class JobsController : Controller
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When a rework job reaches a terminal status, close out the linked ReworkRecord
|
||||||
|
// on the original job so the shop doesn't have to do it manually.
|
||||||
|
// Cancelled → WrittenOff; any other terminal → Resolved.
|
||||||
|
if (newStatus?.IsTerminalStatus == true && job.IsReworkJob)
|
||||||
|
{
|
||||||
|
var linkedRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
||||||
|
r => r.ReworkJobId == job.Id && r.CompanyId == job.CompanyId, false);
|
||||||
|
foreach (var rr in linkedRecords)
|
||||||
|
{
|
||||||
|
if (rr.Status == ReworkStatus.Resolved || rr.Status == ReworkStatus.WrittenOff)
|
||||||
|
continue;
|
||||||
|
rr.Status = newStatus.StatusCode == AppConstants.StatusCodes.Job.Cancelled
|
||||||
|
? ReworkStatus.WrittenOff
|
||||||
|
: ReworkStatus.Resolved;
|
||||||
|
rr.ResolvedDate ??= DateTime.UtcNow;
|
||||||
|
rr.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.ReworkRecords.UpdateAsync(rr);
|
||||||
|
}
|
||||||
|
if (linkedRecords.Any())
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
// Notify customer on status change (only if user opted in)
|
// Notify customer on status change (only if user opted in)
|
||||||
if (request.SendEmail && newStatus != null)
|
if (request.SendEmail && newStatus != null)
|
||||||
{
|
{
|
||||||
@@ -2907,6 +3009,9 @@ public class JobsController : Controller
|
|||||||
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
IsGenericItem = ji.IsGenericItem || (!ji.CatalogItemId.HasValue && ji.Coats.Count == 0),
|
||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
IsAiItem = ji.IsAiItem,
|
IsAiItem = ji.IsAiItem,
|
||||||
|
IsCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
RequiresSandblasting = ji.RequiresSandblasting,
|
RequiresSandblasting = ji.RequiresSandblasting,
|
||||||
RequiresMasking = ji.RequiresMasking,
|
RequiresMasking = ji.RequiresMasking,
|
||||||
Notes = ji.Notes,
|
Notes = ji.Notes,
|
||||||
@@ -2925,6 +3030,7 @@ public class JobsController : Controller
|
|||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb,
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
PowderToOrder = c.PowderToOrder,
|
PowderToOrder = c.PowderToOrder,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge,
|
||||||
Notes = c.Notes
|
Notes = c.Notes
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
PrepServices = ji.PrepServices.Select(ps => new CreateQuoteItemPrepServiceDto
|
||||||
@@ -3098,6 +3204,9 @@ public class JobsController : Controller
|
|||||||
IsLaborItem = ji.IsLaborItem,
|
IsLaborItem = ji.IsLaborItem,
|
||||||
IsSalesItem = ji.IsSalesItem,
|
IsSalesItem = ji.IsSalesItem,
|
||||||
IsAiItem = ji.IsAiItem,
|
IsAiItem = ji.IsAiItem,
|
||||||
|
IsCustomFormulaItem = ji.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = ji.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = ji.FormulaFieldValuesJson,
|
||||||
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
ManualUnitPrice = ji.ManualUnitPrice ?? ((ji.IsGenericItem || ji.IsSalesItem) ? ji.UnitPrice : (decimal?)null),
|
||||||
IncludePrepCost = ji.IncludePrepCost,
|
IncludePrepCost = ji.IncludePrepCost,
|
||||||
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
Coats = ji.Coats.OrderBy(c => c.Sequence).Select(c => new CreateQuoteItemCoatDto
|
||||||
@@ -3105,7 +3214,8 @@ public class JobsController : Controller
|
|||||||
InventoryItemId = c.InventoryItemId,
|
InventoryItemId = c.InventoryItemId,
|
||||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||||
TransferEfficiency = c.TransferEfficiency,
|
TransferEfficiency = c.TransferEfficiency,
|
||||||
PowderCostPerLb = c.PowderCostPerLb
|
PowderCostPerLb = c.PowderCostPerLb,
|
||||||
|
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||||
}).ToList()
|
}).ToList()
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
@@ -3156,7 +3266,7 @@ public class JobsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate)
|
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate)
|
||||||
{
|
{
|
||||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
|
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
|
||||||
ViewBag.InventoryCoatings = inventory
|
ViewBag.InventoryCoatings = inventory
|
||||||
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
||||||
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||||
@@ -3176,12 +3286,12 @@ public class JobsController : Controller
|
|||||||
isIncoming = i.IsIncoming
|
isIncoming = i.IsIncoming
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
|
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
|
||||||
ViewBag.Vendors = vendors
|
ViewBag.Vendors = vendors
|
||||||
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
||||||
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
||||||
|
|
||||||
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
|
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
|
||||||
ViewBag.CatalogItems = catalogItems
|
ViewBag.CatalogItems = catalogItems
|
||||||
.Where(i => i.IsActive)
|
.Where(i => i.IsActive)
|
||||||
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
||||||
@@ -3210,10 +3320,10 @@ public class JobsController : Controller
|
|||||||
description = i.Description
|
description = i.Description
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
|
||||||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||||||
|
|
||||||
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
|
||||||
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
|
ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
|
||||||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -3230,6 +3340,13 @@ public class JobsController : Controller
|
|||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.UseMetric = useMetric;
|
ViewBag.UseMetric = useMetric;
|
||||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||||
|
|
||||||
|
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId && t.IsActive);
|
||||||
|
ViewBag.CustomFormulaTemplates = formulaTemplates.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(t => new { id = t.Id, name = t.Name, description = t.Description, outputMode = t.OutputMode,
|
||||||
|
fieldsJson = t.FieldsJson, formula = t.Formula, defaultRate = t.DefaultRate, rateLabel = t.RateLabel,
|
||||||
|
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath) ? null
|
||||||
|
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id }) }).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3471,10 +3588,13 @@ public class JobsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Records a rework event against a job item (e.g. defect found during QC).
|
/// Records a rework event against a job. Optionally creates a linked rework job so the
|
||||||
/// Automatically creates a new linked rework Job so the repair work can be tracked
|
/// repair can flow through the full shop lifecycle. When creating a rework job:
|
||||||
/// through the same job lifecycle. The rework job inherits the original job's customer,
|
/// - Job number uses sub-number format: {parentNumber}-R{n} (e.g. JOB-2605-0007-R1)
|
||||||
/// oven, and items so the shop has a complete specification to work from.
|
/// - Only items selected by the user are copied (partial rework support)
|
||||||
|
/// - Pricing obeys the ReworkPricingType: ShopFault zeros all item prices;
|
||||||
|
/// CustomerReduced/CustomerFull copy prices as-is (user edits after if needed)
|
||||||
|
/// - Job starts at the first non-Pending status in the company's workflow
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
|
||||||
@@ -3483,66 +3603,41 @@ public class JobsController : Controller
|
|||||||
if (job == null) return NotFound();
|
if (job == null) return NotFound();
|
||||||
|
|
||||||
var companyId = job.CompanyId;
|
var companyId = job.CompanyId;
|
||||||
|
Job? reworkJob = null;
|
||||||
|
|
||||||
// Generate rework job number
|
if (dto.CreateReworkJob && dto.ReworkJobItemIds != null && dto.ReworkJobItemIds.Count > 0 && dto.ReworkPricingType.HasValue)
|
||||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
|
||||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
|
||||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
|
||||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
|
||||||
|
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
|
||||||
var year = DateTime.Now.ToString("yy");
|
|
||||||
var month = DateTime.Now.ToString("MM");
|
|
||||||
var prefix = $"JOB-{year}{month}-";
|
|
||||||
var maxNum = allJobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
|
||||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
|
||||||
.DefaultIfEmpty(0).Max();
|
|
||||||
|
|
||||||
var reworkJob = pendingStatus != null ? new Job
|
|
||||||
{
|
{
|
||||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
var typeLabel = dto.ReworkType switch
|
||||||
CustomerId = job.CustomerId,
|
|
||||||
Description = $"REWORK: {job.Description}",
|
|
||||||
JobStatusId = pendingStatus.Id,
|
|
||||||
JobPriorityId = normalPriority.Id,
|
|
||||||
IsReworkJob = true,
|
|
||||||
OriginalJobId = job.Id,
|
|
||||||
SpecialInstructions = $"Rework of {job.JobNumber}.",
|
|
||||||
CompanyId = companyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
if (reworkJob != null)
|
|
||||||
{
|
{
|
||||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
ReworkType.InternalDefect => "Internal Defect",
|
||||||
await _unitOfWork.CompleteAsync();
|
ReworkType.CustomerWarranty => "Customer Warranty",
|
||||||
|
ReworkType.CustomerDamage => "Customer Damage",
|
||||||
// Copy items: specific item if flagged, otherwise all items
|
_ => dto.ReworkType.ToString()
|
||||||
var itemsToCopy = dto.JobItemId.HasValue
|
};
|
||||||
? job.JobItems.Where(i => i.Id == dto.JobItemId.Value).ToList()
|
var reasonLabel = dto.Reason switch
|
||||||
: job.JobItems.ToList();
|
|
||||||
|
|
||||||
foreach (var item in itemsToCopy)
|
|
||||||
{
|
{
|
||||||
var createdAtUtc = DateTime.UtcNow;
|
ReworkReason.AdhesionFailure => "Adhesion Failure",
|
||||||
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
ReworkReason.Contamination => "Contamination",
|
||||||
|
ReworkReason.ColorMismatch => "Color Mismatch",
|
||||||
await _unitOfWork.JobItems.AddAsync(newItem);
|
ReworkReason.RunsSags => "Runs / Sags",
|
||||||
await _unitOfWork.CompleteAsync();
|
ReworkReason.SurfacePrepFailure => "Surface Prep Failure",
|
||||||
|
ReworkReason.OvenIssue => "Oven Issue",
|
||||||
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
ReworkReason.InsufficientCoverage => "Insufficient Coverage",
|
||||||
|
ReworkReason.HandlingDamage => "Handling Damage",
|
||||||
|
_ => "Other"
|
||||||
|
};
|
||||||
|
var pricingLabel = dto.ReworkPricingType.Value switch
|
||||||
{
|
{
|
||||||
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||||
}
|
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||||
|
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var defect = string.IsNullOrWhiteSpace(dto.DefectDescription) ? "" : $": {dto.DefectDescription}";
|
||||||
|
var reworkDescription = $"REWORK ({typeLabel} / {reasonLabel}){defect}. Pricing: {pricingLabel}.";
|
||||||
|
|
||||||
foreach (var prepService in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
var currentUserId = _userManager.GetUserId(User);
|
||||||
{
|
reworkJob = await BuildReworkJobAsync(job, dto.ReworkJobItemIds, dto.ReworkPricingType.Value, companyId, reworkDescription, currentUserId);
|
||||||
await _unitOfWork.JobItemPrepServices.AddAsync(prepService);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var record = new ReworkRecord
|
var record = new ReworkRecord
|
||||||
@@ -3558,6 +3653,7 @@ public class JobsController : Controller
|
|||||||
EstimatedReworkCost = dto.EstimatedReworkCost,
|
EstimatedReworkCost = dto.EstimatedReworkCost,
|
||||||
IsBillableToCustomer = dto.IsBillableToCustomer,
|
IsBillableToCustomer = dto.IsBillableToCustomer,
|
||||||
BillingNotes = dto.BillingNotes,
|
BillingNotes = dto.BillingNotes,
|
||||||
|
ReworkPricingType = dto.ReworkPricingType,
|
||||||
ReworkJobId = reworkJob?.Id,
|
ReworkJobId = reworkJob?.Id,
|
||||||
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
Status = reworkJob != null ? ReworkStatus.InProgress : ReworkStatus.Open,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
@@ -3567,11 +3663,147 @@ public class JobsController : Controller
|
|||||||
await _unitOfWork.ReworkRecords.AddAsync(record);
|
await _unitOfWork.ReworkRecords.AddAsync(record);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// Reload with navigation for response
|
|
||||||
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
|
var saved = await _unitOfWork.ReworkRecords.FindAsync(r => r.Id == record.Id, false, r => r.JobItem, r => r.ReworkJob);
|
||||||
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
|
return Json(_mapper.Map<ReworkRecordDto>(saved.First()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a linked rework Job from an existing rework record that was saved without one.
|
||||||
|
/// Uses sub-number format and applies the specified pricing attribution.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
||||||
|
{
|
||||||
|
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
||||||
|
if (reworkRecord == null) return NotFound();
|
||||||
|
|
||||||
|
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
|
||||||
|
if (originalJob == null) return NotFound();
|
||||||
|
|
||||||
|
var companyId = originalJob.CompanyId;
|
||||||
|
var itemIds = req.ItemIds ?? originalJob.JobItems.Select(i => i.Id).ToList();
|
||||||
|
var pricingType = req.ReworkPricingType ?? ReworkPricingType.ShopFault;
|
||||||
|
|
||||||
|
var pricingLabel = pricingType switch
|
||||||
|
{
|
||||||
|
ReworkPricingType.ShopFault => "Shop Fault — no charge",
|
||||||
|
ReworkPricingType.CustomerReduced => "Customer responsible — reduced rate",
|
||||||
|
ReworkPricingType.CustomerFull => "Customer responsible — full price",
|
||||||
|
_ => ""
|
||||||
|
};
|
||||||
|
var notes = string.IsNullOrWhiteSpace(req.Notes) ? "" : $" Notes: {req.Notes}";
|
||||||
|
var reworkDescription = $"REWORK: {pricingLabel}.{notes}";
|
||||||
|
var currentUserId = _userManager.GetUserId(User);
|
||||||
|
var reworkJob = await BuildReworkJobAsync(originalJob, itemIds, pricingType, companyId, reworkDescription, currentUserId);
|
||||||
|
|
||||||
|
reworkRecord.ReworkJobId = reworkJob.Id;
|
||||||
|
reworkRecord.ReworkPricingType = pricingType;
|
||||||
|
reworkRecord.Status = ReworkStatus.InProgress;
|
||||||
|
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared helper that creates and persists a rework Job with sub-numbered job number,
|
||||||
|
/// copies the specified items (with coats and prep services), applies pricing attribution,
|
||||||
|
/// sets descriptive job description from the rework record data, and auto-records intake
|
||||||
|
/// (parts are already on hand when rework is logged).
|
||||||
|
/// Called by both AddReworkRecord and CreateReworkJob.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<Job> BuildReworkJobAsync(
|
||||||
|
Job originalJob,
|
||||||
|
List<int> itemIds,
|
||||||
|
ReworkPricingType pricingType,
|
||||||
|
int companyId,
|
||||||
|
string reworkDescription,
|
||||||
|
string? checkedByUserId)
|
||||||
|
{
|
||||||
|
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
||||||
|
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
||||||
|
|
||||||
|
// First non-Pending status by workflow order
|
||||||
|
var firstActiveStatus = statuses
|
||||||
|
.Where(s => s.StatusCode != AppConstants.StatusCodes.Job.Pending)
|
||||||
|
.OrderBy(s => s.DisplayOrder)
|
||||||
|
.First();
|
||||||
|
|
||||||
|
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
||||||
|
|
||||||
|
// Sub-number: {parentJobNumber}-R{n+1}
|
||||||
|
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
|
||||||
|
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
|
||||||
|
|
||||||
|
var reworkJob = new Job
|
||||||
|
{
|
||||||
|
JobNumber = reworkNumber,
|
||||||
|
CustomerId = originalJob.CustomerId,
|
||||||
|
Description = reworkDescription,
|
||||||
|
JobStatusId = firstActiveStatus.Id,
|
||||||
|
JobPriorityId = normalPriority.Id,
|
||||||
|
IsReworkJob = true,
|
||||||
|
OriginalJobId = originalJob.Id,
|
||||||
|
SpecialInstructions = $"Rework of {originalJob.JobNumber}.",
|
||||||
|
// Auto-intake: parts are already on hand when rework is logged
|
||||||
|
IntakeDate = DateTime.UtcNow,
|
||||||
|
IntakeConditionNotes = $"Parts auto-checked in as rework from {originalJob.JobNumber}.",
|
||||||
|
IntakeCheckedByUserId = checkedByUserId,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
var itemsToCopy = originalJob.JobItems.Where(i => itemIds.Contains(i.Id)).ToList();
|
||||||
|
var createdAtUtc = DateTime.UtcNow;
|
||||||
|
|
||||||
|
foreach (var item in itemsToCopy)
|
||||||
|
{
|
||||||
|
var newItem = _jobItemAssemblyService.CreateJobItem(item, reworkJob.Id, companyId, createdAtUtc);
|
||||||
|
|
||||||
|
// Shop-fault rework jobs are done at no charge
|
||||||
|
if (pricingType == ReworkPricingType.ShopFault)
|
||||||
|
{
|
||||||
|
newItem.UnitPrice = 0;
|
||||||
|
newItem.ManualUnitPrice = 0;
|
||||||
|
newItem.TotalPrice = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.JobItems.AddAsync(newItem);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
foreach (var coat in _jobItemAssemblyService.CreateJobItemCoats(item, newItem.Id, companyId, createdAtUtc))
|
||||||
|
await _unitOfWork.JobItemCoats.AddAsync(coat);
|
||||||
|
|
||||||
|
foreach (var prep in _jobItemAssemblyService.CreateJobItemPrepServices(item, newItem.Id, companyId, createdAtUtc))
|
||||||
|
await _unitOfWork.JobItemPrepServices.AddAsync(prep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set intake part count now that items are known
|
||||||
|
reworkJob.IntakePartCount = (int)Math.Ceiling(itemsToCopy.Sum(i => i.Quantity));
|
||||||
|
|
||||||
|
// Write a pricing snapshot so the Details page and inline edit both work correctly
|
||||||
|
var itemsSubtotal = pricingType == ReworkPricingType.ShopFault
|
||||||
|
? 0m
|
||||||
|
: itemsToCopy.Sum(i => i.TotalPrice);
|
||||||
|
reworkJob.FinalPrice = itemsSubtotal;
|
||||||
|
reworkJob.PricingBreakdownJson = System.Text.Json.JsonSerializer.Serialize(new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
ItemsSubtotal = itemsSubtotal,
|
||||||
|
SubtotalBeforeDiscount = itemsSubtotal,
|
||||||
|
SubtotalAfterDiscount = itemsSubtotal,
|
||||||
|
Total = itemsSubtotal
|
||||||
|
});
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.UpdateAsync(reworkJob);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return reworkJob;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates a rework record's status, resolution notes, cost, and billability.
|
/// Updates a rework record's status, resolution notes, cost, and billability.
|
||||||
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
|
/// Auto-sets ResolvedDate when status transitions to Resolved or WrittenOff (if not already set).
|
||||||
@@ -3623,66 +3855,6 @@ public class JobsController : Controller
|
|||||||
return Json(new { success = true });
|
return Json(new { success = true });
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a new rework Job from an existing rework record and links them.
|
|
||||||
/// The rework job is a lightweight clone of the original job — same customer, description, and
|
|
||||||
/// oven — but starts fresh with Pending status so it goes through the full workflow again.
|
|
||||||
/// The ReworkJob FK on the rework record is updated so the Detail view can link to it.
|
|
||||||
/// </summary>
|
|
||||||
[HttpPost]
|
|
||||||
public async Task<IActionResult> CreateReworkJob([FromBody] CreateReworkJobRequest req)
|
|
||||||
{
|
|
||||||
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
|
|
||||||
if (reworkRecord == null) return NotFound();
|
|
||||||
|
|
||||||
var originalJob = reworkRecord.Job;
|
|
||||||
var companyId = originalJob.CompanyId;
|
|
||||||
|
|
||||||
// Load status lookups to find Pending status
|
|
||||||
var statuses = await _lookupCache.GetJobStatusLookupsAsync(companyId);
|
|
||||||
var pendingStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Pending);
|
|
||||||
if (pendingStatus == null) return Json(new { success = false, message = "Could not find Pending status." });
|
|
||||||
|
|
||||||
var priorities = await _lookupCache.GetJobPriorityLookupsAsync(companyId);
|
|
||||||
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
|
|
||||||
|
|
||||||
// Generate job number
|
|
||||||
var allJobs = await _unitOfWork.Jobs.GetAllAsync(true);
|
|
||||||
var year = DateTime.Now.ToString("yy");
|
|
||||||
var month = DateTime.Now.ToString("MM");
|
|
||||||
var prefix = $"JOB-{year}{month}-";
|
|
||||||
var maxNum = allJobs
|
|
||||||
.Where(j => j.JobNumber.StartsWith(prefix))
|
|
||||||
.Select(j => { int.TryParse(j.JobNumber.Replace(prefix, ""), out int n); return n; })
|
|
||||||
.DefaultIfEmpty(0).Max();
|
|
||||||
|
|
||||||
var reworkJob = new Job
|
|
||||||
{
|
|
||||||
JobNumber = $"{prefix}{(maxNum + 1):D4}",
|
|
||||||
CustomerId = originalJob.CustomerId,
|
|
||||||
Description = $"REWORK: {originalJob.Description}",
|
|
||||||
JobStatusId = pendingStatus.Id,
|
|
||||||
JobPriorityId = normalPriority.Id,
|
|
||||||
IsReworkJob = true,
|
|
||||||
OriginalJobId = originalJob.Id,
|
|
||||||
SpecialInstructions = $"Rework of {originalJob.JobNumber}. {req.Notes}".Trim().TrimEnd('.') + ".",
|
|
||||||
CompanyId = companyId,
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
|
|
||||||
await _unitOfWork.Jobs.AddAsync(reworkJob);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
// Link rework record to new job
|
|
||||||
reworkRecord.ReworkJobId = reworkJob.Id;
|
|
||||||
reworkRecord.Status = ReworkStatus.InProgress;
|
|
||||||
reworkRecord.UpdatedAt = DateTime.UtcNow;
|
|
||||||
await _unitOfWork.ReworkRecords.UpdateAsync(reworkRecord);
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
return Json(new { success = true, reworkJobId = reworkJob.Id, reworkJobNumber = reworkJob.JobNumber });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
// ── Quote-Changed Banner Actions ──────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3926,10 +4098,11 @@ public class JobsController : Controller
|
|||||||
ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours;
|
ovenCost = opCosts.OvenOperatingCostPerHour * defaultOvenCycleHours;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Revenue
|
// 4. Revenue — prefer FinalPrice (reflects inline edits and job-level changes);
|
||||||
decimal revenue = job.Invoice != null
|
// fall back to Invoice.Total only when FinalPrice is zero (voided/zeroed job).
|
||||||
? job.Invoice.Total
|
decimal revenue = job.FinalPrice > 0
|
||||||
: (job.FinalPrice > 0 ? job.FinalPrice : job.QuotedPrice);
|
? job.FinalPrice
|
||||||
|
: (job.Invoice?.Total ?? job.QuotedPrice);
|
||||||
|
|
||||||
// 5. Rework costs from linked rework jobs
|
// 5. Rework costs from linked rework jobs
|
||||||
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
var reworkRecords = await _unitOfWork.ReworkRecords.FindAsync(
|
||||||
@@ -3965,7 +4138,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
return Json(new {
|
return Json(new {
|
||||||
revenue = Math.Round(revenue, 2),
|
revenue = Math.Round(revenue, 2),
|
||||||
revenueSource = job.Invoice != null ? "Invoice" : (job.FinalPrice > 0 ? "Final Price" : "Quoted Price"),
|
revenueSource = job.FinalPrice > 0 ? "Final Price" : (job.Invoice != null ? "Invoice" : "Quoted Price"),
|
||||||
powderCost = Math.Round(powderCost, 2),
|
powderCost = Math.Round(powderCost, 2),
|
||||||
laborCost = Math.Round(laborCost, 2),
|
laborCost = Math.Round(laborCost, 2),
|
||||||
ovenCost = Math.Round(ovenCost, 2),
|
ovenCost = Math.Round(ovenCost, 2),
|
||||||
@@ -4159,9 +4332,92 @@ public class JobsController : Controller
|
|||||||
return Json(new { success = false, message = "An error occurred. Please try again." });
|
return Json(new { success = false, message = "An error occurred. Please try again." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single job line item.
|
||||||
|
/// Adjusts FinalPrice and the stored PricingBreakdownJson snapshot by the price delta.
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchJobItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.JobItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var job = await _unitOfWork.Jobs.GetByIdAsync(item.JobId);
|
||||||
|
if (job == null || job.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
var oldTotal = item.TotalPrice;
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.JobItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
var delta = item.TotalPrice - oldTotal;
|
||||||
|
job.FinalPrice = Math.Round(job.FinalPrice + delta, 2);
|
||||||
|
|
||||||
|
// Keep the stored pricing snapshot in sync so the breakdown panel stays consistent.
|
||||||
|
// Case-insensitive options handle JSON stored before PascalCase serialization was enforced.
|
||||||
|
QuotePricingBreakdownDto? pbFinal = null;
|
||||||
|
var jsonOpts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||||
|
if (!string.IsNullOrEmpty(job.PricingBreakdownJson))
|
||||||
|
{
|
||||||
|
var pb = JsonSerializer.Deserialize<QuotePricingBreakdownDto>(job.PricingBreakdownJson, jsonOpts);
|
||||||
|
if (pb != null)
|
||||||
|
{
|
||||||
|
pb.ItemsSubtotal += delta;
|
||||||
|
pb.SubtotalBeforeDiscount += delta;
|
||||||
|
pb.SubtotalAfterDiscount = pb.SubtotalBeforeDiscount - pb.DiscountAmount;
|
||||||
|
pb.TaxAmount = Math.Round(pb.SubtotalAfterDiscount * pb.TaxPercent / 100m, 2);
|
||||||
|
pb.Total = Math.Round(pb.SubtotalAfterDiscount + pb.RushFee + pb.TaxAmount, 2);
|
||||||
|
job.FinalPrice = pb.Total;
|
||||||
|
job.PricingBreakdownJson = JsonSerializer.Serialize(pb);
|
||||||
|
pbFinal = pb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.Jobs.UpdateAsync(job);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// For legacy jobs without a stored snapshot, derive breakdown from live item totals.
|
||||||
|
if (pbFinal == null)
|
||||||
|
{
|
||||||
|
var allItems = await _unitOfWork.JobItems.FindAsync(ji => ji.JobId == job.Id && !ji.IsDeleted);
|
||||||
|
var itemsSubtotal = allItems.Sum(ji => ji.TotalPrice);
|
||||||
|
var subtotal = itemsSubtotal + job.OvenBatchCost + job.ShopSuppliesAmount;
|
||||||
|
pbFinal = new QuotePricingBreakdownDto
|
||||||
|
{
|
||||||
|
ItemsSubtotal = itemsSubtotal,
|
||||||
|
SubtotalBeforeDiscount = subtotal,
|
||||||
|
SubtotalAfterDiscount = subtotal,
|
||||||
|
Total = job.FinalPrice
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
finalPrice = job.FinalPrice,
|
||||||
|
itemsSubtotal = pbFinal.ItemsSubtotal,
|
||||||
|
subtotalBeforeDiscount = pbFinal.SubtotalBeforeDiscount,
|
||||||
|
subtotalAfterDiscount = pbFinal.SubtotalAfterDiscount,
|
||||||
|
taxAmount = pbFinal.TaxAmount
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
public class DeleteTimeEntryRequest { public int Id { get; set; } }
|
||||||
|
public class PatchJobItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
public class LogMaterialRequest
|
public class LogMaterialRequest
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
@@ -4170,7 +4426,13 @@ public class LogMaterialRequest
|
|||||||
public string TransactionType { get; set; } = "JobUsage";
|
public string TransactionType { get; set; } = "JobUsage";
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
public class CreateReworkJobRequest { public int ReworkRecordId { get; set; } public string? Notes { get; set; } }
|
public class CreateReworkJobRequest
|
||||||
|
{
|
||||||
|
public int ReworkRecordId { get; set; }
|
||||||
|
public List<int>? ItemIds { get; set; }
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public class UpdateWorkerAssignmentRequest
|
public class UpdateWorkerAssignmentRequest
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ public class JobsPriorityController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Get priorities and workers for modal options
|
// Get priorities and workers for modal options
|
||||||
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||||
var workers = await _userManager.Users
|
var workers = await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||||
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
|
||||||
|
|||||||
@@ -16,15 +16,18 @@ public class MaintenanceController : Controller
|
|||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly ILogger<MaintenanceController> _logger;
|
private readonly ILogger<MaintenanceController> _logger;
|
||||||
|
|
||||||
public MaintenanceController(
|
public MaintenanceController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IMapper mapper,
|
IMapper mapper,
|
||||||
|
ITenantContext tenantContext,
|
||||||
ILogger<MaintenanceController> logger)
|
ILogger<MaintenanceController> logger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +580,7 @@ public class MaintenanceController : Controller
|
|||||||
// Calculate next scheduled maintenance
|
// Calculate next scheduled maintenance
|
||||||
if (equipment.RecommendedMaintenanceIntervalDays > 0)
|
if (equipment.RecommendedMaintenanceIntervalDays > 0)
|
||||||
{
|
{
|
||||||
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays);
|
equipment.NextScheduledMaintenance = DateTime.UtcNow.AddDays(equipment.RecommendedMaintenanceIntervalDays.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
equipment.UpdatedAt = DateTime.UtcNow;
|
equipment.UpdatedAt = DateTime.UtcNow;
|
||||||
@@ -740,7 +743,8 @@ public class MaintenanceController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
|
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null)
|
||||||
{
|
{
|
||||||
var equipment = await _unitOfWork.Equipment.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId);
|
||||||
ViewBag.EquipmentList = new SelectList(
|
ViewBag.EquipmentList = new SelectList(
|
||||||
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
|
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
|
||||||
"Id",
|
"Id",
|
||||||
|
|||||||
@@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller
|
|||||||
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
|
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
|
||||||
{
|
{
|
||||||
var goal = req?.OptimizationGoal ?? "maximize_throughput";
|
var goal = req?.OptimizationGoal ?? "maximize_throughput";
|
||||||
|
var suggestCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var equipmentList = (await _unitOfWork.OvenCosts.GetAllAsync())
|
var equipmentList = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == suggestCompanyId))
|
||||||
.Where(o => o.IsActive)
|
.Where(o => o.IsActive)
|
||||||
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
|
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller
|
|||||||
if (!equipmentList.Any())
|
if (!equipmentList.Any())
|
||||||
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." });
|
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." });
|
||||||
|
|
||||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
|
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == suggestCompanyId);
|
||||||
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
||||||
|
|
||||||
var queueJobs = (await _unitOfWork.Jobs.GetAllAsync(
|
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
|
j => j.CompanyId == suggestCompanyId,
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus,
|
j => j.JobStatus,
|
||||||
@@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller
|
|||||||
if (req?.Batches == null || !req.Batches.Any())
|
if (req?.Batches == null || !req.Batches.Any())
|
||||||
return Json(new { success = false, error = "No batches provided." });
|
return Json(new { success = false, error = "No batches provided." });
|
||||||
|
|
||||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
|
var acceptCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == acceptCompanyId);
|
||||||
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
||||||
|
|
||||||
var createdBatches = new List<object>();
|
var createdBatches = new List<object>();
|
||||||
@@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller
|
|||||||
if (oven == null)
|
if (oven == null)
|
||||||
return Json(new { success = false, error = "Oven not found." });
|
return Json(new { success = false, error = "Oven not found." });
|
||||||
|
|
||||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync();
|
var createBatchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == createBatchCompanyId);
|
||||||
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
|
||||||
|
|
||||||
var batchNumber = await GenerateBatchNumberAsync();
|
var batchNumber = await GenerateBatchNumberAsync();
|
||||||
@@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller
|
|||||||
if (inOvenStatus != null)
|
if (inOvenStatus != null)
|
||||||
{
|
{
|
||||||
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
|
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet();
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync()).Where(j => jobIds.Contains(j.Id));
|
var startBatchCid = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == startBatchCid && jobIds.Contains(j.Id));
|
||||||
foreach (var job in jobs)
|
foreach (var job in jobs)
|
||||||
job.JobStatusId = inOvenStatus.Id;
|
job.JobStatusId = inOvenStatus.Id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ public class PaymentController : Controller
|
|||||||
CustomerName = customer != null
|
CustomerName = customer != null
|
||||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||||
: "Valued Customer",
|
: "Valued Customer",
|
||||||
CustomerEmail = customer?.Email ?? string.Empty,
|
|
||||||
CompanyName = company!.CompanyName,
|
CompanyName = company!.CompanyName,
|
||||||
BalanceDue = invoice.BalanceDue,
|
BalanceDue = invoice.BalanceDue,
|
||||||
InvoiceTotal = invoice.Total,
|
InvoiceTotal = invoice.Total,
|
||||||
@@ -127,8 +126,6 @@ public class PaymentController : Controller
|
|||||||
return BadRequest(new { error = "Invalid payment amount." });
|
return BadRequest(new { error = "Invalid payment amount." });
|
||||||
|
|
||||||
var surcharge = CalculateSurcharge(request.Amount, company!);
|
var surcharge = CalculateSurcharge(request.Amount, company!);
|
||||||
var customer = await _context.Customers.AsNoTracking()
|
|
||||||
.FirstOrDefaultAsync(c => c.Id == invoice.CustomerId);
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreatePaymentIntentAsync(
|
await _stripeConnect.CreatePaymentIntentAsync(
|
||||||
@@ -136,7 +133,6 @@ public class PaymentController : Controller
|
|||||||
invoiceTotal: request.Amount,
|
invoiceTotal: request.Amount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customer?.Email ?? string.Empty,
|
|
||||||
invoiceNumber: invoice.InvoiceNumber,
|
invoiceNumber: invoice.InvoiceNumber,
|
||||||
invoiceId: invoice.Id);
|
invoiceId: invoice.Id);
|
||||||
|
|
||||||
@@ -261,7 +257,6 @@ public class PaymentController : Controller
|
|||||||
CustomerName = customer != null
|
CustomerName = customer != null
|
||||||
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()
|
||||||
: quote.ProspectContactName ?? "Valued Customer",
|
: quote.ProspectContactName ?? "Valued Customer",
|
||||||
CustomerEmail = customer?.Email ?? quote.ProspectEmail ?? string.Empty,
|
|
||||||
CompanyName = company!.CompanyName,
|
CompanyName = company!.CompanyName,
|
||||||
DepositAmount = depositAmount,
|
DepositAmount = depositAmount,
|
||||||
QuoteTotal = quote.Total,
|
QuoteTotal = quote.Total,
|
||||||
@@ -296,7 +291,6 @@ public class PaymentController : Controller
|
|||||||
|
|
||||||
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
var depositAmount = Math.Round(quote!.Total * (quote.DepositPercent / 100m), 2);
|
||||||
var surcharge = CalculateSurcharge(depositAmount, company!);
|
var surcharge = CalculateSurcharge(depositAmount, company!);
|
||||||
var customerEmail = quote.Customer?.Email ?? quote.ProspectEmail ?? string.Empty;
|
|
||||||
|
|
||||||
var (success, clientSecret, paymentIntentId, stripeError) =
|
var (success, clientSecret, paymentIntentId, stripeError) =
|
||||||
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
await _stripeConnect.CreateDepositPaymentIntentAsync(
|
||||||
@@ -304,7 +298,6 @@ public class PaymentController : Controller
|
|||||||
depositAmount: depositAmount,
|
depositAmount: depositAmount,
|
||||||
surchargeAmount: surcharge,
|
surchargeAmount: surcharge,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
customerEmail: customerEmail,
|
|
||||||
quoteNumber: quote.QuoteNumber,
|
quoteNumber: quote.QuoteNumber,
|
||||||
quoteId: quote.Id);
|
quoteId: quote.Id);
|
||||||
|
|
||||||
@@ -942,7 +935,6 @@ public class PaymentPageViewModel
|
|||||||
public int InvoiceId { get; set; }
|
public int InvoiceId { get; set; }
|
||||||
public string Token { get; set; } = string.Empty;
|
public string Token { get; set; } = string.Empty;
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string CustomerEmail { get; set; } = string.Empty;
|
|
||||||
public string CompanyName { get; set; } = string.Empty;
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
public decimal BalanceDue { get; set; }
|
public decimal BalanceDue { get; set; }
|
||||||
public decimal InvoiceTotal { get; set; }
|
public decimal InvoiceTotal { get; set; }
|
||||||
@@ -963,7 +955,6 @@ public class DepositPaymentPageViewModel
|
|||||||
public int QuoteId { get; set; }
|
public int QuoteId { get; set; }
|
||||||
public string Token { get; set; } = string.Empty;
|
public string Token { get; set; } = string.Empty;
|
||||||
public string CustomerName { get; set; } = string.Empty;
|
public string CustomerName { get; set; } = string.Empty;
|
||||||
public string CustomerEmail { get; set; } = string.Empty;
|
|
||||||
public string CompanyName { get; set; } = string.Empty;
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
public decimal DepositAmount { get; set; }
|
public decimal DepositAmount { get; set; }
|
||||||
public decimal QuoteTotal { get; set; }
|
public decimal QuoteTotal { get; set; }
|
||||||
|
|||||||
@@ -14,12 +14,14 @@ public class PricingTiersController : Controller
|
|||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IMapper _mapper;
|
private readonly IMapper _mapper;
|
||||||
private readonly ILogger<PricingTiersController> _logger;
|
private readonly ILogger<PricingTiersController> _logger;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|
||||||
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger)
|
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger, ITenantContext tenantContext)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_mapper = mapper;
|
_mapper = mapper;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -27,8 +29,9 @@ public class PricingTiersController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var tiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
|
|
||||||
var customerCountByTier = customers
|
var customerCountByTier = customers
|
||||||
.Where(c => c.PricingTierId.HasValue)
|
.Where(c => c.PricingTierId.HasValue)
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
|
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
|
||||||
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
|
||||||
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any();
|
var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId)).Any();
|
||||||
ViewBag.QuotingNotCalibrated = costs != null
|
ViewBag.QuotingNotCalibrated = costs != null
|
||||||
&& !hasNamedSetups
|
&& !hasNamedSetups
|
||||||
&& costs.CompressorCfm == 0
|
&& costs.CompressorCfm == 0
|
||||||
@@ -441,7 +441,7 @@ public class QuotesController : Controller
|
|||||||
ViewBag.Deposits = quoteDeposits;
|
ViewBag.Deposits = quoteDeposits;
|
||||||
|
|
||||||
// Customer list for inline customer-change dropdown
|
// Customer list for inline customer-change dropdown
|
||||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == quote.CompanyId);
|
||||||
ViewBag.CustomerSelectList = allCustomers
|
ViewBag.CustomerSelectList = allCustomers
|
||||||
.Where(c => c.IsActive)
|
.Where(c => c.IsActive)
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
@@ -2429,8 +2429,27 @@ public class QuotesController : Controller
|
|||||||
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
|
var (_, quotePhotoMax) = await _subscriptionService.GetQuotePhotoCountAsync(companyId, 0);
|
||||||
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
|
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
|
||||||
|
|
||||||
|
var formulaTemplates = await _unitOfWork.CustomItemTemplates.FindAsync(
|
||||||
|
t => t.CompanyId == companyId && t.IsActive);
|
||||||
|
ViewBag.CustomFormulaTemplates = formulaTemplates
|
||||||
|
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
id = t.Id,
|
||||||
|
name = t.Name,
|
||||||
|
description = t.Description,
|
||||||
|
outputMode = t.OutputMode,
|
||||||
|
fieldsJson = t.FieldsJson,
|
||||||
|
formula = t.Formula,
|
||||||
|
defaultRate = t.DefaultRate,
|
||||||
|
rateLabel = t.RateLabel,
|
||||||
|
diagramImagePath = string.IsNullOrEmpty(t.DiagramImagePath)
|
||||||
|
? (string?)null
|
||||||
|
: Url.Action("TemplateDiagram", "CompanySettings", new { templateId = t.Id })
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
// Customers
|
// Customers
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
ViewBag.Customers = customers
|
ViewBag.Customers = customers
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
{
|
{
|
||||||
@@ -2471,7 +2490,7 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
|
// Inventory coatings — include incoming items so they can be quoted while powder is in transit
|
||||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory);
|
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory);
|
||||||
ViewBag.InventoryCoatings = inventory
|
ViewBag.InventoryCoatings = inventory
|
||||||
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating)
|
||||||
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
.OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name)
|
||||||
@@ -2492,13 +2511,13 @@ public class QuotesController : Controller
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Vendors
|
// Vendors
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync(false);
|
var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
|
||||||
ViewBag.Vendors = vendors
|
ViewBag.Vendors = vendors
|
||||||
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
.Where(s => s.IsActive).OrderBy(s => s.CompanyName)
|
||||||
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
|
||||||
|
|
||||||
// Catalog items
|
// Catalog items
|
||||||
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory);
|
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory);
|
||||||
ViewBag.CatalogItems = catalogItems
|
ViewBag.CatalogItems = catalogItems
|
||||||
.Where(i => i.IsActive)
|
.Where(i => i.IsActive)
|
||||||
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
|
||||||
@@ -2528,11 +2547,11 @@ public class QuotesController : Controller
|
|||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
// Prep services
|
// Prep services
|
||||||
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive);
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId);
|
||||||
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
|
||||||
|
|
||||||
// Blast setups for wizard dropdown
|
// Blast setups for wizard dropdown
|
||||||
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive);
|
var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId);
|
||||||
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
|
||||||
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -2599,7 +2618,8 @@ public class QuotesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulatePricingTiersDropDownAsync()
|
private async Task PopulatePricingTiersDropDownAsync()
|
||||||
{
|
{
|
||||||
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var pricingTiers = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId);
|
||||||
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
|
ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
|
||||||
.Select(pt => new SelectListItem
|
.Select(pt => new SelectListItem
|
||||||
{
|
{
|
||||||
@@ -2825,9 +2845,9 @@ public class QuotesController : Controller
|
|||||||
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
|
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
|
||||||
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
|
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
|
||||||
|
|
||||||
// Get default job statuses and priorities
|
// Get default job statuses and priorities — scope to quote's company for defense-in-depth
|
||||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
|
||||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
|
||||||
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
|
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
|
||||||
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
|
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
|
||||||
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
|
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
|
||||||
@@ -3347,7 +3367,7 @@ public class QuotesController : Controller
|
|||||||
CompanyBlastSetup? selectedBlastSetup = null;
|
CompanyBlastSetup? selectedBlastSetup = null;
|
||||||
if (request.BlastSetupId.HasValue)
|
if (request.BlastSetupId.HasValue)
|
||||||
{
|
{
|
||||||
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive);
|
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
|
||||||
selectedBlastSetup = setups.FirstOrDefault();
|
selectedBlastSetup = setups.FirstOrDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3823,6 +3843,49 @@ public class QuotesController : Controller
|
|||||||
}
|
}
|
||||||
return (company.LogoData, company.LogoContentType);
|
return (company.LogoData, company.LogoContentType);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Inline-edits description, quantity, and unit price on a single quote line item.
|
||||||
|
/// Adjusts stored quote totals by the price delta so the sidebar stays accurate.
|
||||||
|
/// Returns updated totals so the page can reflect the change without a reload.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PatchItem([FromBody] PatchQuoteItemRequest request)
|
||||||
|
{
|
||||||
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
|
var item = await _unitOfWork.QuoteItems.GetByIdAsync(request.ItemId);
|
||||||
|
if (item == null) return NotFound();
|
||||||
|
|
||||||
|
var quote = await _unitOfWork.Quotes.GetByIdAsync(item.QuoteId);
|
||||||
|
if (quote == null || quote.CompanyId != currentUser.CompanyId) return NotFound();
|
||||||
|
|
||||||
|
var oldTotal = item.TotalPrice;
|
||||||
|
item.Description = request.Description.Trim();
|
||||||
|
item.Quantity = request.Quantity;
|
||||||
|
item.UnitPrice = request.UnitPrice;
|
||||||
|
item.TotalPrice = Math.Round(request.Quantity * request.UnitPrice, 2);
|
||||||
|
await _unitOfWork.QuoteItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
// Cascade delta through stored totals without re-running the pricing engine
|
||||||
|
var delta = item.TotalPrice - oldTotal;
|
||||||
|
quote.ItemsSubtotal += delta;
|
||||||
|
quote.SubTotal += delta;
|
||||||
|
quote.SubtotalAfterDiscount = quote.SubTotal - quote.DiscountAmount;
|
||||||
|
quote.TaxAmount = Math.Round(quote.SubtotalAfterDiscount * quote.TaxPercent / 100m, 2);
|
||||||
|
quote.Total = Math.Round(quote.SubtotalAfterDiscount + quote.RushFee + quote.TaxAmount, 2);
|
||||||
|
await _unitOfWork.Quotes.UpdateAsync(quote);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return Json(new {
|
||||||
|
lineTotal = item.TotalPrice,
|
||||||
|
subtotal = quote.SubTotal,
|
||||||
|
taxAmount = quote.TaxAmount,
|
||||||
|
total = quote.Total
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request model for AJAX pricing calculation
|
// Request model for AJAX pricing calculation
|
||||||
@@ -3833,3 +3896,11 @@ public class UpdateQuoteStatusRequest
|
|||||||
public int QuoteId { get; set; }
|
public int QuoteId { get; set; }
|
||||||
public int StatusId { get; set; }
|
public int StatusId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class PatchQuoteItemRequest
|
||||||
|
{
|
||||||
|
public int ItemId { get; set; }
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ public class RecurringTemplatesController : Controller
|
|||||||
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
|
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var templates = await _unitOfWork.RecurringTemplates.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId);
|
||||||
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
|
return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,11 +426,12 @@ public class RecurringTemplatesController : Controller
|
|||||||
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
|
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
|
||||||
private async Task PopulateDropDownsAsync()
|
private async Task PopulateDropDownsAsync()
|
||||||
{
|
{
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId);
|
||||||
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
|
ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
|
||||||
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
|
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList();
|
||||||
|
|
||||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
|
||||||
ViewBag.APAccounts = accounts
|
ViewBag.APAccounts = accounts
|
||||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces;
|
|||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
using PowderCoating.Web.ViewModels.Reports;
|
using PowderCoating.Web.ViewModels.Reports;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -25,8 +26,9 @@ public class ReportsController : Controller
|
|||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly IAccountingAiService _accountingAi;
|
private readonly IAccountingAiService _accountingAi;
|
||||||
private readonly IAiUsageLogger _usageLogger;
|
private readonly IAiUsageLogger _usageLogger;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
|
|
||||||
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
|
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -36,6 +38,7 @@ public class ReportsController : Controller
|
|||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_accountingAi = accountingAi;
|
_accountingAi = accountingAi;
|
||||||
_usageLogger = usageLogger;
|
_usageLogger = usageLogger;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -79,27 +82,26 @@ public class ReportsController : Controller
|
|||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
||||||
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Load only necessary data - optimized with filtering and minimal eager loading
|
// Load only necessary data — all explicitly scoped to this company
|
||||||
// Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution)
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
||||||
// Note: Date filtering would exclude data needed for jobsByStatus calculation
|
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
|
||||||
|
|
||||||
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
|
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel)
|
||||||
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus)).ToList();
|
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus)).ToList();
|
||||||
|
|
||||||
// Customers: Load all (needed for active count and customer creation trend across all months)
|
// Customers: Load all (needed for active count and customer creation trend across all months)
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Equipment: Load all for status distribution
|
// Equipment: Load all for status distribution
|
||||||
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
|
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Inventory: Load all for low stock analysis
|
// Inventory: Load all for low stock analysis
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
|
|
||||||
// Appointments: Filter to relevant date range at DB level
|
// Appointments: Filter to relevant date range at DB level
|
||||||
var appointments = (await _unitOfWork.Appointments.FindAsync(
|
var appointments = (await _unitOfWork.Appointments.FindAsync(
|
||||||
a => a.ScheduledStartTime >= startDate,
|
a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
|
||||||
false,
|
false,
|
||||||
a => a.Customer,
|
a => a.Customer,
|
||||||
a => a.AppointmentType,
|
a => a.AppointmentType,
|
||||||
@@ -108,7 +110,7 @@ public class ReportsController : Controller
|
|||||||
// Users with assigned jobs/appointments will be loaded below when building worker stats
|
// Users with assigned jobs/appointments will be loaded below when building worker stats
|
||||||
|
|
||||||
// CatalogItems: Load all for category distribution
|
// CatalogItems: Load all for category distribution
|
||||||
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
|
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
|
||||||
|
|
||||||
// === OVERVIEW METRICS ===
|
// === OVERVIEW METRICS ===
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
@@ -382,7 +384,7 @@ public class ReportsController : Controller
|
|||||||
.ToDictionary(g => g.Key, g => g.Count());
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
|
|
||||||
// === FINANCIAL ANALYTICS ===
|
// === FINANCIAL ANALYTICS ===
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
|
|
||||||
var totalInvoiced = activeInvoices.Sum(i => i.Total);
|
var totalInvoiced = activeInvoices.Sum(i => i.Total);
|
||||||
@@ -781,7 +783,7 @@ public class ReportsController : Controller
|
|||||||
|
|
||||||
// === POWDER CONSUMPTION VS PURCHASE ===
|
// === POWDER CONSUMPTION VS PURCHASE ===
|
||||||
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
|
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
|
||||||
.GetAllAsync(false, t => t.InventoryItem))
|
.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var powderConsumptionItems = allInventoryTransactions
|
var powderConsumptionItems = allInventoryTransactions
|
||||||
@@ -1309,14 +1311,15 @@ public class ReportsController : Controller
|
|||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
||||||
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
|
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
|
||||||
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
|
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus);
|
var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
|
||||||
var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList();
|
var appointments = allAppointments.ToList();
|
||||||
|
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
@@ -1384,7 +1387,8 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startDate = now.AddMonths(-months);
|
var startDate = now.AddMonths(-months);
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList();
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
|
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList();
|
||||||
var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
@@ -1430,12 +1434,13 @@ public class ReportsController : Controller
|
|||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
|
||||||
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
|
||||||
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList();
|
var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus);
|
var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
|
||||||
var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList();
|
var appointments = allAppts.ToList();
|
||||||
|
|
||||||
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
@@ -1483,10 +1488,11 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startDate = now.AddMonths(-months);
|
var startDate = now.AddMonths(-months);
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList();
|
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
|
||||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
|
var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList();
|
||||||
|
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority))
|
||||||
.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
|
|
||||||
var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
|
var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count());
|
||||||
@@ -1523,7 +1529,8 @@ public class ReportsController : Controller
|
|||||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
|
var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList();
|
||||||
var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
|
var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList();
|
||||||
@@ -1574,7 +1581,8 @@ public class ReportsController : Controller
|
|||||||
|
|
||||||
var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
|
var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
|
||||||
// Also load collected payments for P&L comparison
|
// Also load collected payments for P&L comparison
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)).ToList();
|
||||||
var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
|
var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
|
||||||
for (var i = months - 1; i >= 0; i--)
|
for (var i = months - 1; i >= 0; i--)
|
||||||
@@ -1609,8 +1617,10 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startDate = now.AddMonths(-months);
|
var startDate = now.AddMonths(-months);
|
||||||
var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem))
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList();
|
var powderTransactions = (await _unitOfWork.InventoryTransactions.FindAsync(
|
||||||
|
t => t.CompanyId == companyId && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
|
var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId)
|
||||||
.Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
.Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
||||||
@@ -1631,7 +1641,8 @@ public class ReportsController : Controller
|
|||||||
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
|
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
|
||||||
public async Task<IActionResult> SalesByCustomer(int months = 6)
|
public async Task<IActionResult> SalesByCustomer(int months = 6)
|
||||||
{
|
{
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
var items = activeInvoices.Where(i => i.Customer != null)
|
var items = activeInvoices.Where(i => i.Customer != null)
|
||||||
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
|
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial })
|
||||||
@@ -1650,8 +1661,9 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
|
||||||
|
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var items = customers.Where(c => c.IsActive).Select(c =>
|
var items = customers.Where(c => c.IsActive).Select(c =>
|
||||||
{
|
{
|
||||||
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
|
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
|
||||||
@@ -1682,7 +1694,8 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
|
||||||
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
|
||||||
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
|
||||||
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
|
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
|
||||||
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
|
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
|
||||||
@@ -1720,7 +1733,8 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" };
|
||||||
var activeJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var activeJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
|
||||||
var items = activeJobs.Select(j => new JobStatusAgingItem
|
var items = activeJobs.Select(j => new JobStatusAgingItem
|
||||||
{
|
{
|
||||||
JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
|
JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(),
|
||||||
@@ -1740,7 +1754,8 @@ public class ReportsController : Controller
|
|||||||
{
|
{
|
||||||
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
||||||
.Select(i =>
|
.Select(i =>
|
||||||
{
|
{
|
||||||
@@ -1758,7 +1773,8 @@ public class ReportsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> PowderConsumption(int months = 6)
|
public async Task<IActionResult> PowderConsumption(int months = 6)
|
||||||
{
|
{
|
||||||
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
|
||||||
var items = allTx.Where(t => t.InventoryItem != null)
|
var items = allTx.Where(t => t.InventoryItem != null)
|
||||||
.GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
|
.GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer })
|
||||||
.Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
.Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() })
|
||||||
@@ -1776,8 +1792,9 @@ public class ReportsController : Controller
|
|||||||
public async Task<IActionResult> InventoryTurnover(int months = 6)
|
public async Task<IActionResult> InventoryTurnover(int months = 6)
|
||||||
{
|
{
|
||||||
var daysInPeriod = months * 30.0;
|
var daysInPeriod = months * 30.0;
|
||||||
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList();
|
var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
|
||||||
|
var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList();
|
||||||
var items = inventory.Where(i => i.IsActive).Select(i =>
|
var items = inventory.Where(i => i.IsActive).Select(i =>
|
||||||
{
|
{
|
||||||
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
|
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
|
||||||
@@ -1835,8 +1852,9 @@ public class ReportsController : Controller
|
|||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
// Load invoices for AR data
|
// Load invoices for AR data
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();
|
var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList();
|
||||||
|
|
||||||
@@ -1930,13 +1948,14 @@ public class ReportsController : Controller
|
|||||||
var companyName = await GetCompanyNameAsync();
|
var companyName = await GetCompanyNameAsync();
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
// Open AR invoices
|
// Open AR invoices
|
||||||
var openInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
|
var openInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
|
||||||
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Compute avg days to pay per customer from paid invoices
|
// Compute avg days to pay per customer from paid invoices
|
||||||
var paidInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments))
|
var paidInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments))
|
||||||
.Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
|
.Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
|
||||||
.ToList();
|
.ToList();
|
||||||
var avgDaysByCustomer = paidInvoices
|
var avgDaysByCustomer = paidInvoices
|
||||||
@@ -2137,7 +2156,8 @@ public class ReportsController : Controller
|
|||||||
var companyName = await GetCompanyNameAsync();
|
var companyName = await GetCompanyNameAsync();
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList();
|
||||||
var activeInvoices = allInvoices.Where(i =>
|
var activeInvoices = allInvoices.Where(i =>
|
||||||
i.Status != InvoiceStatus.Voided &&
|
i.Status != InvoiceStatus.Voided &&
|
||||||
i.Status != InvoiceStatus.WrittenOff).ToList();
|
i.Status != InvoiceStatus.WrittenOff).ToList();
|
||||||
@@ -2256,8 +2276,9 @@ public class ReportsController : Controller
|
|||||||
var companyName = await GetCompanyNameAsync();
|
var companyName = await GetCompanyNameAsync();
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var startOfYear = new DateTime(now.Year, 1, 1);
|
var startOfYear = new DateTime(now.Year, 1, 1);
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments))
|
var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments))
|
||||||
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
|
.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers;
|
|||||||
public class SmsConsentAuditController : Controller
|
public class SmsConsentAuditController : Controller
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly ITenantContext _tenantContext;
|
||||||
private readonly ILogger<SmsConsentAuditController> _logger;
|
private readonly ILogger<SmsConsentAuditController> _logger;
|
||||||
|
|
||||||
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
|
public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger<SmsConsentAuditController> logger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
_tenantContext = tenantContext;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
{
|
{
|
||||||
@@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var customers = (await _unitOfWork.Customers.GetAllAsync())
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
|
||||||
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ public class TaxRatesController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var rates = await _unitOfWork.TaxRates.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var rates = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId);
|
||||||
return View(rates.OrderBy(r => r.Name).ToList());
|
return View(rates.OrderBy(r => r.Name).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,8 @@ public class ToolsController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetImportAccounts()
|
public async Task<IActionResult> GetImportAccounts()
|
||||||
{
|
{
|
||||||
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
|
||||||
|
|
||||||
var revenue = allAccounts
|
var revenue = allAccounts
|
||||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||||
@@ -123,7 +124,8 @@ public class ToolsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateImportAccountDropdownsAsync()
|
private async Task PopulateImportAccountDropdownsAsync()
|
||||||
{
|
{
|
||||||
var allAccounts = await _unitOfWork.Accounts.GetAllAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId);
|
||||||
|
|
||||||
var revenueAccounts = allAccounts
|
var revenueAccounts = allAccounts
|
||||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
||||||
@@ -1102,7 +1104,7 @@ public class ToolsController : Controller
|
|||||||
|
|
||||||
// Validate account IDs belong to this company — stale page load can produce IDs
|
// Validate account IDs belong to this company — stale page load can produce IDs
|
||||||
// that were valid before a data reset but no longer exist.
|
// that were valid before a data reset but no longer exist.
|
||||||
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
|
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
|
||||||
.Select(a => a.Id).ToHashSet();
|
.Select(a => a.Id).ToHashSet();
|
||||||
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
|
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
|
||||||
revenueAccountId = null;
|
revenueAccountId = null;
|
||||||
@@ -1167,7 +1169,7 @@ public class ToolsController : Controller
|
|||||||
|
|
||||||
// Validate account IDs belong to this company — stale page load can produce IDs
|
// Validate account IDs belong to this company — stale page load can produce IDs
|
||||||
// that were valid before a data reset but no longer exist.
|
// that were valid before a data reset but no longer exist.
|
||||||
var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync())
|
var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value))
|
||||||
.Select(a => a.Id).ToHashSet();
|
.Select(a => a.Id).ToHashSet();
|
||||||
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
|
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
|
||||||
inventoryAccountId = null;
|
inventoryAccountId = null;
|
||||||
@@ -1939,7 +1941,7 @@ public class ToolsController : Controller
|
|||||||
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
|
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||||
{
|
{
|
||||||
// 1. Customers
|
// 1. Customers
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value);
|
||||||
var customersCsv = GenerateCustomersCsv(customers);
|
var customersCsv = GenerateCustomersCsv(customers);
|
||||||
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
|
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
|
||||||
using (var entryStream = customersEntry.Open())
|
using (var entryStream = customersEntry.Open())
|
||||||
@@ -1949,7 +1951,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Quotes
|
// 2. Quotes
|
||||||
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
|
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId.Value, false, q => q.Customer, q => q.QuoteStatus);
|
||||||
var quotesCsv = GenerateQuotesCsv(quotes);
|
var quotesCsv = GenerateQuotesCsv(quotes);
|
||||||
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
|
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
|
||||||
using (var entryStream = quotesEntry.Open())
|
using (var entryStream = quotesEntry.Open())
|
||||||
@@ -1959,7 +1961,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Jobs
|
// 3. Jobs
|
||||||
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
||||||
var jobsCsv = GenerateJobsCsv(jobs);
|
var jobsCsv = GenerateJobsCsv(jobs);
|
||||||
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
|
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
|
||||||
using (var entryStream = jobsEntry.Open())
|
using (var entryStream = jobsEntry.Open())
|
||||||
@@ -1969,7 +1971,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Appointments
|
// 4. Appointments
|
||||||
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
|
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId.Value, false,
|
||||||
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
|
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
|
||||||
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
|
var appointmentsCsv = GenerateAppointmentsCsv(appointments);
|
||||||
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
|
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
|
||||||
@@ -1980,9 +1982,9 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 5. Catalog
|
// 5. Catalog
|
||||||
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
|
||||||
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
||||||
var catalog = await _unitOfWork.CatalogItems.GetAllAsync();
|
var catalog = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
|
||||||
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
|
var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
|
||||||
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
|
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
|
||||||
using (var entryStream = catalogEntry.Open())
|
using (var entryStream = catalogEntry.Open())
|
||||||
@@ -1992,7 +1994,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. Inventory
|
// 6. Inventory
|
||||||
var inventory = await _unitOfWork.InventoryItems.GetAllAsync();
|
var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value);
|
||||||
var inventoryCsv = GenerateInventoryCsv(inventory);
|
var inventoryCsv = GenerateInventoryCsv(inventory);
|
||||||
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
|
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
|
||||||
using (var entryStream = inventoryEntry.Open())
|
using (var entryStream = inventoryEntry.Open())
|
||||||
@@ -2002,7 +2004,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 7. Equipment
|
// 7. Equipment
|
||||||
var equipment = await _unitOfWork.Equipment.GetAllAsync();
|
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
|
||||||
var equipmentCsv = GenerateEquipmentCsv(equipment);
|
var equipmentCsv = GenerateEquipmentCsv(equipment);
|
||||||
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
|
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
|
||||||
using (var entryStream = equipmentEntry.Open())
|
using (var entryStream = equipmentEntry.Open())
|
||||||
@@ -2012,7 +2014,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 8. Maintenance
|
// 8. Maintenance
|
||||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
|
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId.Value, false, m => m.Equipment);
|
||||||
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
|
var maintenanceCsv = GenerateMaintenanceCsv(maintenance);
|
||||||
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
|
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
|
||||||
using (var entryStream = maintenanceEntry.Open())
|
using (var entryStream = maintenanceEntry.Open())
|
||||||
@@ -2022,7 +2024,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. Vendors
|
// 9. Vendors
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
|
||||||
var vendorsCsv = GenerateVendorsCsv(vendors);
|
var vendorsCsv = GenerateVendorsCsv(vendors);
|
||||||
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
|
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
|
||||||
using (var entryStream = vendorsEntry.Open())
|
using (var entryStream = vendorsEntry.Open())
|
||||||
@@ -2032,7 +2034,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 10. Prep Services
|
// 10. Prep Services
|
||||||
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
|
||||||
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
|
var prepServicesCsv = GeneratePrepServicesCsv(prepServices);
|
||||||
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
|
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
|
||||||
using (var entryStream = prepServicesEntry.Open())
|
using (var entryStream = prepServicesEntry.Open())
|
||||||
@@ -2042,7 +2044,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 11. Invoices
|
// 11. Invoices
|
||||||
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
|
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
|
||||||
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
||||||
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
||||||
using (var entryStream = invoicesEntry.Open())
|
using (var entryStream = invoicesEntry.Open())
|
||||||
@@ -2052,7 +2054,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 12. Chart of Accounts
|
// 12. Chart of Accounts
|
||||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
|
var accountsCsv = GenerateChartOfAccountsCsv(accounts);
|
||||||
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
|
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
|
||||||
using (var entryStream = accountsEntry.Open())
|
using (var entryStream = accountsEntry.Open())
|
||||||
@@ -2062,7 +2064,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 13. Expenses
|
// 13. Expenses
|
||||||
var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
||||||
var expensesCsv = GenerateExpensesCsv(expenses);
|
var expensesCsv = GenerateExpensesCsv(expenses);
|
||||||
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
|
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
|
||||||
using (var entryStream = expensesEntry.Open())
|
using (var entryStream = expensesEntry.Open())
|
||||||
@@ -2072,7 +2074,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 14. Payments
|
// 14. Payments
|
||||||
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
|
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
|
||||||
var paymentsCsv = GeneratePaymentsCsv(payments);
|
var paymentsCsv = GeneratePaymentsCsv(payments);
|
||||||
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
||||||
using (var entryStream = paymentsEntry.Open())
|
using (var entryStream = paymentsEntry.Open())
|
||||||
@@ -2258,9 +2260,9 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync();
|
var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
|
||||||
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories);
|
||||||
var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync();
|
var catalogItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value);
|
||||||
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
|
var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
|
||||||
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2326,7 +2328,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var equipment = await _unitOfWork.Equipment.GetAllAsync();
|
var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
|
||||||
var csv = GenerateEquipmentCsv(equipment);
|
var csv = GenerateEquipmentCsv(equipment);
|
||||||
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2407,13 +2409,13 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load all lookup tables
|
// Load all lookup tables — scoped to this company
|
||||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
|
||||||
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync();
|
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||||
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
|
||||||
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync();
|
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
|
||||||
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync();
|
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
|
||||||
|
|
||||||
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
|
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
|
||||||
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
|
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
|
||||||
@@ -4092,7 +4094,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var prepServices = await _unitOfWork.PrepServices.GetAllAsync();
|
var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value);
|
||||||
var csv = GeneratePrepServicesCsv(prepServices);
|
var csv = GeneratePrepServicesCsv(prepServices);
|
||||||
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -4124,7 +4126,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
|
||||||
var csv = GenerateVendorsCsv(vendors);
|
var csv = GenerateVendorsCsv(vendors);
|
||||||
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -4156,7 +4158,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
var csv = GenerateChartOfAccountsCsv(accounts);
|
var csv = GenerateChartOfAccountsCsv(accounts);
|
||||||
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ public class VendorsController : Controller
|
|||||||
public async Task<IActionResult> Create(bool inline = false)
|
public async Task<IActionResult> Create(bool inline = false)
|
||||||
{
|
{
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync();
|
||||||
if (inline)
|
if (inline)
|
||||||
return PartialView(new CreateVendorDto());
|
return PartialView(new CreateVendorDto());
|
||||||
return View(new CreateVendorDto());
|
return View(new CreateVendorDto());
|
||||||
@@ -207,6 +208,7 @@ public class VendorsController : Controller
|
|||||||
return Json(new { success = false, errors });
|
return Json(new { success = false, errors });
|
||||||
}
|
}
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync(dto.CategoryIds);
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +218,12 @@ public class VendorsController : Controller
|
|||||||
var vendor = _mapper.Map<Vendor>(dto);
|
var vendor = _mapper.Map<Vendor>(dto);
|
||||||
vendor.CompanyId = currentUser!.CompanyId;
|
vendor.CompanyId = currentUser!.CompanyId;
|
||||||
|
|
||||||
|
if (dto.CategoryIds.Any())
|
||||||
|
{
|
||||||
|
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
|
||||||
|
vendor.Categories = cats.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.Vendors.AddAsync(vendor);
|
await _unitOfWork.Vendors.AddAsync(vendor);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -247,14 +255,16 @@ public class VendorsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value);
|
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id.Value, false, v => v.Categories);
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var dto = _mapper.Map<UpdateVendorDto>(vendor);
|
var dto = _mapper.Map<UpdateVendorDto>(vendor);
|
||||||
|
dto.CategoryIds = vendor.Categories.Select(c => c.Id).ToList();
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync(dto.CategoryIds);
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -282,18 +292,27 @@ public class VendorsController : Controller
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
{
|
{
|
||||||
await PopulateExpenseAccountsAsync();
|
await PopulateExpenseAccountsAsync();
|
||||||
|
await PopulateVendorCategoriesAsync(dto.CategoryIds);
|
||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id);
|
var vendor = await _unitOfWork.Vendors.GetByIdAsync(id, false, v => v.Categories);
|
||||||
if (vendor == null)
|
if (vendor == null)
|
||||||
{
|
{
|
||||||
return NotFound();
|
return NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
_mapper.Map(dto, vendor);
|
_mapper.Map(dto, vendor);
|
||||||
|
|
||||||
|
vendor.Categories.Clear();
|
||||||
|
if (dto.CategoryIds.Any())
|
||||||
|
{
|
||||||
|
var cats = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => dto.CategoryIds.Contains(c.Id));
|
||||||
|
foreach (var cat in cats) vendor.Categories.Add(cat);
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
await _unitOfWork.Vendors.UpdateAsync(vendor);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
@@ -413,6 +432,20 @@ public class VendorsController : Controller
|
|||||||
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
|
/// purchases) in addition to regular operating expenses. A "— None —" placeholder is prepended so
|
||||||
/// the field is optional — not every vendor needs a default account pre-set.
|
/// the field is optional — not every vendor needs a default account pre-set.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Populates ViewBag.VendorCategories with active inventory categories for the checkbox list,
|
||||||
|
/// and ViewBag.SelectedCategoryIds with the IDs already assigned to the vendor being edited.
|
||||||
|
/// </summary>
|
||||||
|
private async Task PopulateVendorCategoriesAsync(IEnumerable<int>? selectedIds = null)
|
||||||
|
{
|
||||||
|
var companyId = (await _userManager.GetUserAsync(User))!.CompanyId;
|
||||||
|
var cats = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId && c.IsActive))
|
||||||
|
.OrderBy(c => c.DisplayOrder)
|
||||||
|
.ToList();
|
||||||
|
ViewBag.VendorCategories = cats;
|
||||||
|
ViewBag.SelectedCategoryIds = (selectedIds ?? Enumerable.Empty<int>()).ToHashSet();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task PopulateExpenseAccountsAsync()
|
private async Task PopulateExpenseAccountsAsync()
|
||||||
{
|
{
|
||||||
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
||||||
|
|||||||
@@ -215,6 +215,8 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown — click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated.
|
**Per-item cost breakdown:** On the Quote Details page, each line item shows a collapsible cost breakdown — click the row to expand it and see how material, labor, equipment, complexity, and markup were calculated for that specific item. This is useful for spotting which items are underpriced or where costs are concentrated.
|
||||||
|
|
||||||
|
**Inline item editing on quotes:** On the Quote Details page, any unit price, quantity, or item description can be edited in-place by clicking the value directly. Press Enter or click away to save; press Escape to cancel. The pricing summary (subtotal, discount, tax, and total) updates immediately without reloading the page.
|
||||||
|
|
||||||
**Pricing Mode (Markup vs Margin):** In Company Settings → Operating Costs you can choose between two pricing modes:
|
**Pricing Mode (Markup vs Margin):** In Company Settings → Operating Costs you can choose between two pricing modes:
|
||||||
- *Markup on Materials* (default) — the General Markup % is applied as a markup on top of calculated costs: `price = cost × (1 + markup%)`. A 25% markup on a $100 cost = $125.
|
- *Markup on Materials* (default) — the General Markup % is applied as a markup on top of calculated costs: `price = cost × (1 + markup%)`. A 25% markup on a $100 cost = $125.
|
||||||
- *Target Margin on Total Cost* — the markup % is treated as a target gross margin: `price = cost ÷ (1 − margin%)`. A 25% margin on a $100 cost = $133.33. The difference grows at higher percentages.
|
- *Target Margin on Total Cost* — the markup % is treated as a target gross margin: `price = cost ÷ (1 − margin%)`. A 25% margin on a $100 cost = $133.33. The difference grows at higher percentages.
|
||||||
@@ -265,12 +267,15 @@ public static class HelpKnowledgeBase
|
|||||||
**Job Priorities (color-coded):**
|
**Job Priorities (color-coded):**
|
||||||
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
- Low (grey), Normal (blue), High (orange), Urgent (red), Rush (purple)
|
||||||
|
|
||||||
|
**Jobs list default view:** The Jobs list opens on the **On Floor** filter by default — showing only active jobs (excludes Completed, Ready for Pickup, Delivered, Cancelled). Use the filter pills at the top to switch views: **All** shows every job regardless of status; **On Floor** shows active work; **Overdue** shows past-due active jobs; **Ready** shows jobs awaiting customer pickup; **Completed** shows all finished jobs (Completed + Ready for Pickup + Delivered). Each pill shows a live global count.
|
||||||
|
|
||||||
**How to create a job:**
|
**How to create a job:**
|
||||||
1. Go to [Jobs](/Jobs) → "New Job"
|
1. Go to [Jobs](/Jobs) → "New Job"
|
||||||
2. Select customer
|
2. Select customer
|
||||||
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
|
||||||
4. Set priority, due date, assigned worker, special instructions
|
4. Set priority, due date, assigned worker, special instructions
|
||||||
5. Save
|
5. Optionally set Oven & Batch Settings — select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
|
||||||
|
6. Save
|
||||||
|
|
||||||
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
**Job Priority Board:** [/JobsPriority](/JobsPriority) — Kanban-style view of all active jobs sorted by priority and status.
|
||||||
|
|
||||||
@@ -296,13 +301,30 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Time Entries:** Track labor time on a job from the Details page.
|
**Time Entries:** Track labor time on a job from the Details page.
|
||||||
|
|
||||||
**Rework:** If a job needs to be redone, a rework record can be created from the Details page. Tracks rework type, reason, and resolution.
|
**Rework / Redo:** Rework and redo mean the same thing in this system. If a finished part fails QA or a customer brings it back damaged, open the original job's Details page and use the **Rework Log** section to record it.
|
||||||
|
|
||||||
|
Each rework entry captures: the type (internal defect, customer damage, warranty), the reason (adhesion failure, color mismatch, runs/sags, insufficient coverage, etc.), a defect description, who discovered it, and pricing responsibility.
|
||||||
|
|
||||||
|
**Pricing responsibility options:**
|
||||||
|
- **Shop Fault — no charge:** All rework job item prices are set to $0. Use this when the defect is the shop's fault.
|
||||||
|
- **Customer responsible — reduced rate:** Item prices are copied from the original job. Edit them down after the rework job is created.
|
||||||
|
- **Customer responsible — full price:** Item prices are copied from the original job as-is.
|
||||||
|
|
||||||
|
**Creating a rework job:** Toggle "Parts are back — create a Rework Job" in the log form. Select which items need to be redone and choose the pricing responsibility. The system creates a new job with a sub-number (e.g., JOB-2605-0001-R1), copies the selected items with their coats and prep services, and auto-records intake (parts are already on hand). The rework job description shows the defect type, reason, and pricing at the top of the job where it is easy to see.
|
||||||
|
|
||||||
|
**Rework job completion:** When the rework job reaches a terminal status (Completed, Delivered, etc.), the linked rework record on the original job is automatically marked as **Resolved**. If the rework job is Cancelled instead, the record is marked **Written Off**. No manual follow-up on the original job is needed.
|
||||||
|
|
||||||
**Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
|
**Job Templates:** [/JobTemplates](/JobTemplates) — Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
|
||||||
|
|
||||||
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer — a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
|
||||||
|
|
||||||
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value — it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
|
||||||
|
|
||||||
|
**Job Costing revenue:** The Job Costing card uses the job's Final Price as the revenue figure — not the linked invoice total. This means inline price edits are reflected in the profit margin estimate immediately, even before an invoice exists.
|
||||||
|
|
||||||
|
**Completing a job:** When a job is ready to mark complete, click the **Complete Job** button on the Job Details page. A modal appears asking you to confirm the completion date, actual hours spent, and final price. If the job used powder from inventory, you will be asked to enter the actual lbs used — the modal groups all coats by unique powder color (not per coat or per item) so you fill in one quantity per powder. The system deducts the entered amounts from inventory, crediting any quantities already logged via QR scan. Once confirmed, the job advances to Completed status, and you are prompted to create the invoice if one does not exist.
|
||||||
|
|
||||||
|
**Creating an invoice from a job:** On the Job Details page, look for the Invoice section and click "Create Invoice." The system pre-fills all line items, pricing, discount, tax rate, payment terms, and due date from the job and customer automatically. Line item descriptions include the coat color(s) for each item (e.g., "Color1 / Color2" if multiple coats), helping customers distinguish repeated items on the invoice. Review the Totals panel on the right — if a discount was applied to the job it will show as a red "Discount Applied" line. Adjust anything you need, then save.
|
||||||
|
|
||||||
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
**Work Order QR Codes:** Every printed job work order includes two tiers of QR codes — one for viewing the job, and a separate set for taking action on it. All QR codes require the worker to be logged in.
|
||||||
|
|
||||||
@@ -352,6 +374,8 @@ public static class HelpKnowledgeBase
|
|||||||
|
|
||||||
**Payment methods:** Cash, Check, Credit/Debit Card, Bank Transfer (ACH), Digital Payment, Store Credit
|
**Payment methods:** Cash, Check, Credit/Debit Card, Bank Transfer (ACH), Digital Payment, Store Credit
|
||||||
|
|
||||||
|
**Inline item editing on invoices:** On the Invoice Details page, unit prices, quantities, and item descriptions can be edited in-place while the invoice is in Draft status. Click the value — it becomes an input field. Press Enter or click away to save; press Escape to cancel. The invoice total updates immediately. Line items are locked once the invoice is Sent.
|
||||||
|
|
||||||
**Sending an invoice:** Invoice Details → "Send" — emails PDF to customer.
|
**Sending an invoice:** Invoice Details → "Send" — emails PDF to customer.
|
||||||
|
|
||||||
**Online Payments:** [/Invoices/OnlinePayments](/Invoices/OnlinePayments) — Lists invoices with a shareable payment link the customer can pay without logging in. Requires Stripe Connect to be set up first (see below).
|
**Online Payments:** [/Invoices/OnlinePayments](/Invoices/OnlinePayments) — Lists invoices with a shareable payment link the customer can pay without logging in. Requires Stripe Connect to be set up first (see below).
|
||||||
@@ -442,13 +466,21 @@ public static class HelpKnowledgeBase
|
|||||||
Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item).
|
Filter by item, date range, and transaction type. Summary pills show total lbs received, used, and net adjustments. Access from the sidebar ("Inventory Activity") or via "View Activity History" on any item's Details page (pre-filtered to that item).
|
||||||
|
|
||||||
**QR Code Labels & Mobile Usage Logging:**
|
**QR Code Labels & Mobile Usage Logging:**
|
||||||
- Print a QR label from any item's Details page → click "Print QR Label" → opens a standalone print page with the item name, SKU, colour, and QR code. Print and stick on the bag/bin.
|
- Print a QR label from any item's Details page OR from the QR icon in the Actions column on the Inventory list page → click "Print QR Label" → a preview modal opens with the label (item name, SKU, colour, finish, and QR code). Click "Print Label" inside the modal to send to your printer without opening a new tab.
|
||||||
|
- You can also print from the list page without opening the item: click the QR icon (first button in the action column) next to any row.
|
||||||
- Scan the QR code with a phone camera → opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id]
|
- Scan the QR code with a phone camera → opens the mobile-friendly Log Usage page at /Inventory/Scan/[item-id]
|
||||||
- On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save
|
- On the scan page: select a job (My Jobs shows jobs assigned to the logged-in user; Other Jobs shows all open jobs; No Job logs without a reference), enter quantity used (live balance preview shown), choose reason (Job Usage / Waste / Correction / Transfer Out), add optional notes, tap Save
|
||||||
- After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details"
|
- After saving: success screen with "Log Another Item for This Job" (returns to scan with same job pre-selected) or "Back to Inventory" / "View Item Details"
|
||||||
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
|
- Every scan log is recorded as a JobUsage or Adjustment InventoryTransaction and immediately reduces QuantityOnHand; visible in Inventory Activity ledger
|
||||||
- First-time scan on a new device requires login; browser caches the session after that
|
- First-time scan on a new device requires login; browser caches the session after that
|
||||||
|
|
||||||
|
**Location / Bin filtering and printing:**
|
||||||
|
- Every inventory item has an optional **Location** field (e.g. "Shelf A", "Bin 3") set on the Create/Edit form.
|
||||||
|
- Once any item has a location, a **Location dropdown** appears in the Inventory list filter bar (next to Category). Selecting a location filters the list to only items stored there. Can be combined with keyword search and category filter simultaneously.
|
||||||
|
- Each location badge in the table is a clickable link that instantly filters to that bin.
|
||||||
|
- With a location filter active, a **Print Bin** button appears in the filter banner. Clicking it opens a printer-ready page (new tab) at /Inventory/PrintBin?location=... listing all items in that bin with line number, name, color, and SKU. No site chrome — prints cleanly on a standard sheet.
|
||||||
|
- The Location dropdown only appears when at least one item has a location value set. If a user doesn't see it, they need to fill in the Location field on at least one inventory item.
|
||||||
|
|
||||||
**Catalog Lookup & Label Scanner (when adding/editing inventory items):**
|
**Catalog Lookup & Label Scanner (when adding/editing inventory items):**
|
||||||
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
|
- When creating or editing an inventory item, click the **Lookup** button next to the SKU/Part Number field to search a built-in platform catalog of thousands of Prismatic Powders and other manufacturer SKUs. Select a match to auto-fill name, manufacturer, color code, finish, coverage rate, SDS/TDS links, and cure specs.
|
||||||
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
|
- The catalog only shows products not already in the company's inventory (prevents duplicates). When editing, the item's own catalog entry is always shown.
|
||||||
@@ -1356,5 +1388,25 @@ public static class HelpKnowledgeBase
|
|||||||
---
|
---
|
||||||
|
|
||||||
Remember: if the user asks something outside this knowledge base or asks for something very specific to their data, acknowledge the limits and point them to the relevant page or the Help Center.
|
Remember: if the user asks something outside this knowledge base or asks for something very specific to their data, acknowledge the limits and point them to the relevant page or the Help Center.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Custom Formula Item Templates (Company Settings → Custom Formulas):**
|
||||||
|
Reusable NCalc pricing formulas for complex fabricated items (roof curbs, electrical enclosures, welded frames). Each template has a list of measurement fields and a formula expression. Two output modes:
|
||||||
|
- Fixed Rate: formula produces a dollar amount → stored as ManualUnitPrice × Qty
|
||||||
|
- Surface Area: formula produces sq ft → standard coating engine prices it
|
||||||
|
|
||||||
|
Creating a template: New Template → enter name + output mode + fields (name, label, unit, default value) → write NCalc formula using field names → Run to test → optionally upload a diagram image → Save
|
||||||
|
AI Generator: enter description in "AI Formula Generator" box in the template editor → Claude suggests formula + fields + mode → review and save
|
||||||
|
Using in wizard: item wizard shows "Custom Formula Item" card if active templates exist → choose template → template diagram shown for reference → enter measurements → Calculate → verify result → continue to coatings/prep steps
|
||||||
|
Formula variable names: snake_case, letters/digits/underscores only. Reserved variable: "rate" (pre-populated from Default Rate).
|
||||||
|
NCalc syntax: +, -, *, /, %, Pow(b,e), Abs(x), Round(x,d), Max(a,b), Min(a,b), Sqrt(x)
|
||||||
|
Common formula patterns (all Fixed Rate, divide inches by 144 to get sqft):
|
||||||
|
- 6-sided box: fields l_in/w_in/h_in → 2*(l_in*w_in + l_in*h_in + w_in*h_in) / 144 * rate
|
||||||
|
- Cylinder: fields d_in/h_in → (3.14159 * d_in * h_in + 2 * 3.14159 * Pow(d_in/2, 2)) / 144 * rate
|
||||||
|
- Flat panel: fields l_in/w_in → l_in * w_in / 144 * rate
|
||||||
|
Walkthrough: first time opening Custom Formulas tab with no templates triggers a 7-step guided tour automatically; also accessible via "How it works" button
|
||||||
|
Help article: Help → Custom Formula Item Templates
|
||||||
""";
|
""";
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,6 +180,18 @@ builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
|
|||||||
.AddDefaultUI()
|
.AddDefaultUI()
|
||||||
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
|
.AddClaimsPrincipalFactory<ApplicationUserClaimsPrincipalFactory>();
|
||||||
|
|
||||||
|
// Configure the auth cookie to survive mobile browser suspensions (iOS Safari clears session
|
||||||
|
// cookies when it suspends a tab). Max-Age on the cookie itself makes it persistent regardless
|
||||||
|
// of whether the user checked "Remember me". SlidingExpiration renews the window on each request.
|
||||||
|
builder.Services.ConfigureApplicationCookie(options =>
|
||||||
|
{
|
||||||
|
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||||
|
options.SlidingExpiration = true;
|
||||||
|
options.Cookie.MaxAge = TimeSpan.FromDays(30);
|
||||||
|
options.Cookie.IsEssential = true;
|
||||||
|
options.Cookie.SameSite = SameSiteMode.Lax;
|
||||||
|
});
|
||||||
|
|
||||||
// Register HttpContextAccessor for multi-tenancy
|
// Register HttpContextAccessor for multi-tenancy
|
||||||
builder.Services.AddHttpContextAccessor();
|
builder.Services.AddHttpContextAccessor();
|
||||||
|
|
||||||
@@ -207,6 +219,7 @@ builder.Services.AddScoped<IOperationalReportService, OperationalReportService>(
|
|||||||
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
|
builder.Services.AddScoped<IAiHelpService, AiHelpService>();
|
||||||
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
|
builder.Services.AddScoped<IAiCatalogPriceCheckService, AiCatalogPriceCheckService>();
|
||||||
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
|
builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>();
|
||||||
|
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
||||||
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
||||||
@@ -240,6 +253,7 @@ builder.Services.AddHostedService<AuditLogRetentionBackgroundService>();
|
|||||||
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
||||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||||
|
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||||
@@ -279,6 +293,7 @@ cfg.AddProfile(new CatalogProfile());
|
|||||||
cfg.AddProfile(new AccountingProfile());
|
cfg.AddProfile(new AccountingProfile());
|
||||||
cfg.AddProfile(new PurchaseOrderProfile());
|
cfg.AddProfile(new PurchaseOrderProfile());
|
||||||
cfg.AddProfile(new PricingTierProfile());
|
cfg.AddProfile(new PricingTierProfile());
|
||||||
|
cfg.AddProfile(new CustomItemTemplateProfile());
|
||||||
}, loggerFactory);
|
}, loggerFactory);
|
||||||
return config.CreateMapper();
|
return config.CreateMapper();
|
||||||
});
|
});
|
||||||
@@ -529,6 +544,49 @@ builder.Services.AddRateLimiter(options =>
|
|||||||
{
|
{
|
||||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
// Return a proper HTML body on 429 so mobile browsers (especially iOS Safari) don't try to
|
||||||
|
// download the empty response as a file. Without this, Safari shows "Login / data Zero KB".
|
||||||
|
options.OnRejected = async (context, ct) =>
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
|
||||||
|
var isAjax = context.HttpContext.Request.Headers.XRequestedWith == "XMLHttpRequest"
|
||||||
|
|| (context.HttpContext.Request.Headers.Accept.ToString().Contains("application/json"));
|
||||||
|
|
||||||
|
if (isAjax)
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await context.HttpContext.Response.WriteAsync(
|
||||||
|
"""{"success":false,"error":"Too many requests. Please wait a moment and try again."}""", ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
context.HttpContext.Response.ContentType = "text/html; charset=utf-8";
|
||||||
|
await context.HttpContext.Response.WriteAsync("""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Too Many Requests — Powder Coating Logix</title>
|
||||||
|
<style>
|
||||||
|
body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0;background:#f5f5f5}
|
||||||
|
.card{background:#fff;border-radius:8px;padding:2rem;max-width:420px;text-align:center;box-shadow:0 2px 8px rgba(0,0,0,.1)}
|
||||||
|
h2{margin-top:0;color:#333}p{color:#666}a{color:#0d6efd}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Too Many Requests</h2>
|
||||||
|
<p>You've made too many login attempts in a short period. Please wait a minute and try again.</p>
|
||||||
|
<a href="/Identity/Account/Login">Back to Login</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""", ct);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// login / password-reset — 10 per minute per IP
|
// login / password-reset — 10 per minute per IP
|
||||||
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
|
options.AddPolicy(AppConstants.RateLimitPolicies.Auth, ctx =>
|
||||||
RateLimitPartition.GetSlidingWindowLimiter(
|
RateLimitPartition.GetSlidingWindowLimiter(
|
||||||
@@ -615,8 +673,8 @@ System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = cultureInfo;
|
|||||||
// SECURITY: Add security headers middleware
|
// SECURITY: Add security headers middleware
|
||||||
app.Use(async (context, next) =>
|
app.Use(async (context, next) =>
|
||||||
{
|
{
|
||||||
// Prevent clickjacking
|
// Prevent clickjacking — SAMEORIGIN so our own iframe embeds (QR labels, etc.) still work
|
||||||
context.Response.Headers.Append("X-Frame-Options", "DENY");
|
context.Response.Headers.Append("X-Frame-Options", "SAMEORIGIN");
|
||||||
|
|
||||||
// Prevent MIME type sniffing
|
// Prevent MIME type sniffing
|
||||||
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
context.Response.Headers.Append("X-Content-Type-Options", "nosniff");
|
||||||
@@ -633,7 +691,7 @@ app.Use(async (context, next) =>
|
|||||||
: "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com";
|
: "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://code.jquery.com https://js.stripe.com";
|
||||||
|
|
||||||
var cspConnectSrc = app.Environment.IsDevelopment()
|
var cspConnectSrc = app.Environment.IsDevelopment()
|
||||||
? "'self' wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev
|
? "'self' ws://localhost:* wss://localhost:* https://cdn.jsdelivr.net https://api.stripe.com" // Allow hot reload WebSocket in dev (ws:// for browser-refresh, wss:// for SignalR)
|
||||||
: "'self' https://cdn.jsdelivr.net https://api.stripe.com";
|
: "'self' https://cdn.jsdelivr.net https://api.stripe.com";
|
||||||
|
|
||||||
context.Response.Headers.Append("Content-Security-Policy",
|
context.Response.Headers.Append("Content-Security-Policy",
|
||||||
@@ -643,7 +701,8 @@ app.Use(async (context, next) =>
|
|||||||
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
|
"font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " +
|
||||||
"img-src 'self' data: https:; " +
|
"img-src 'self' data: https:; " +
|
||||||
$"connect-src {cspConnectSrc}; " +
|
$"connect-src {cspConnectSrc}; " +
|
||||||
"frame-src https://js.stripe.com https://hooks.stripe.com");
|
"frame-src 'self' https://js.stripe.com https://hooks.stripe.com; " +
|
||||||
|
"frame-ancestors 'self'");
|
||||||
|
|
||||||
// Referrer Policy - control referrer information
|
// Referrer Policy - control referrer information
|
||||||
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
|
context.Response.Headers.Append("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||||
@@ -652,6 +711,11 @@ app.Use(async (context, next) =>
|
|||||||
context.Response.Headers.Append("Permissions-Policy",
|
context.Response.Headers.Append("Permissions-Policy",
|
||||||
"geolocation=(), microphone=(), camera=()");
|
"geolocation=(), microphone=(), camera=()");
|
||||||
|
|
||||||
|
// Prevent browsers from caching authenticated pages — avoids stale data and
|
||||||
|
// browser-specific cache corruption bugs (e.g. Firefox caching a partial load).
|
||||||
|
if (context.User.Identity?.IsAuthenticated == true)
|
||||||
|
context.Response.Headers.Append("Cache-Control", "no-store");
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -72,14 +72,14 @@
|
|||||||
<input class="form-check-input" type="radio" name="format" value="xlsx" id="fmt_xlsx" checked />
|
<input class="form-check-input" type="radio" name="format" value="xlsx" id="fmt_xlsx" checked />
|
||||||
<label class="form-check-label" for="fmt_xlsx">
|
<label class="form-check-label" for="fmt_xlsx">
|
||||||
<i class="bi bi-file-earmark-spreadsheet me-1 text-success"></i>
|
<i class="bi bi-file-earmark-spreadsheet me-1 text-success"></i>
|
||||||
Excel (.xlsx) — all sheets in one file
|
Excel (.xlsx) — all sheets in one file
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" name="format" value="csv" id="fmt_csv" />
|
<input class="form-check-input" type="radio" name="format" value="csv" id="fmt_csv" />
|
||||||
<label class="form-check-label" for="fmt_csv">
|
<label class="form-check-label" for="fmt_csv">
|
||||||
<i class="bi bi-file-zip me-1 text-warning"></i>
|
<i class="bi bi-file-zip me-1 text-warning"></i>
|
||||||
CSV (.zip) — one file per sheet
|
CSV (.zip) — one file per sheet
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +111,7 @@
|
|||||||
document.getElementById('exportForm').addEventListener('submit', function () {
|
document.getElementById('exportForm').addEventListener('submit', function () {
|
||||||
var btn = document.getElementById('exportBtn');
|
var btn = document.getElementById('exportBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
||||||
// Re-enable after 10s in case browser blocks the download dialog
|
// Re-enable after 10s in case browser blocks the download dialog
|
||||||
setTimeout(function () {
|
setTimeout(function () {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
<div class="mt-1 text-success" style="font-size:1.6rem;"><i class="bi bi-building"></i></div>
|
<div class="mt-1 text-success" style="font-size:1.6rem;"><i class="bi bi-building"></i></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold">QuickBooks Desktop</div>
|
<div class="fw-semibold">QuickBooks Desktop</div>
|
||||||
<div class="text-muted small">IIF files — import directly via File > Utilities > Import</div>
|
<div class="text-muted small">IIF files — import directly via File > Utilities > Import</div>
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<span class="badge bg-light text-dark border me-1">customers.iif</span>
|
<span class="badge bg-light text-dark border me-1">customers.iif</span>
|
||||||
<span class="badge bg-light text-dark border me-1">invoices_payments.iif</span>
|
<span class="badge bg-light text-dark border me-1">invoices_payments.iif</span>
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init — mark CSV as selected by default
|
// Init — mark CSV as selected by default
|
||||||
document.getElementById('fmt-csv').checked = true;
|
document.getElementById('fmt-csv').checked = true;
|
||||||
updateFormatCards();
|
updateFormatCards();
|
||||||
|
|
||||||
@@ -154,7 +154,7 @@
|
|||||||
document.getElementById('exportForm').addEventListener('submit', function() {
|
document.getElementById('exportForm').addEventListener('submit', function() {
|
||||||
const btn = document.getElementById('exportBtn');
|
const btn = document.getElementById('exportBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating…';
|
||||||
setTimeout(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-download me-2"></i>Download Export Package'; }, 8000);
|
setTimeout(function() { btn.disabled = false; btn.innerHTML = '<i class="bi bi-download me-2"></i>Download Export Package'; }, 8000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
|
@model PowderCoating.Application.DTOs.Accounting.CreateAccountDto
|
||||||
@using PowderCoating.Core.Enums
|
@using PowderCoating.Core.Enums
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "New Account";
|
ViewData["Title"] = "New Account";
|
||||||
ViewData["PageIcon"] = "bi-journal-plus";
|
ViewData["PageIcon"] = "bi-journal-plus";
|
||||||
ViewData["PageHelpTitle"] = "New Account";
|
ViewData["PageHelpTitle"] = "New Account";
|
||||||
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
|
ViewData["PageHelpContent"] = "Add a custom account to the Chart of Accounts. Select a Sub-Type first — it auto-sets the Account Type. Use conventional numbering: 1000s = Assets, 2000s = Liabilities, 3000s = Equity, 4000s = Revenue, 5000s = Cost of Goods, 6000s+ = Expenses.";
|
||||||
bool isInline = ViewBag.Inline == true;
|
bool isInline = ViewBag.Inline == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Account Number"
|
data-bs-title="Account Number"
|
||||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
|
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique. Sub-accounts can use decimals (e.g. 6100.1).">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<select asp-for="AccountType" asp-items="ViewBag.AccountTypes" class="form-select" id="accountTypeSelect">
|
<select asp-for="AccountType" asp-items="ViewBag.AccountTypes" class="form-select" id="accountTypeSelect">
|
||||||
<option value="">— Select Type —</option>
|
<option value="">— Select Type —</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="AccountType" class="text-danger small"></span>
|
<span asp-validation-for="AccountType" class="text-danger small"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<select asp-for="AccountSubType" asp-items="ViewBag.AccountSubTypes" class="form-select" id="accountSubTypeSelect">
|
<select asp-for="AccountSubType" asp-items="ViewBag.AccountSubTypes" class="form-select" id="accountSubTypeSelect">
|
||||||
<option value="">— Select Sub-Type —</option>
|
<option value="">— Select Sub-Type —</option>
|
||||||
</select>
|
</select>
|
||||||
<span asp-validation-for="AccountSubType" class="text-danger small"></span>
|
<span asp-validation-for="AccountSubType" class="text-danger small"></span>
|
||||||
<div class="form-text text-primary" id="typeAutoSetHint" style="display:none">
|
<div class="form-text text-primary" id="typeAutoSetHint" style="display:none">
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Parent Account"
|
data-bs-title="Parent Account"
|
||||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
||||||
<option value="">— None (top-level account) —</option>
|
<option value="">— None (top-level account) —</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
// SubType enum values → AccountType enum values (mirrors server-side mapping)
|
// SubType enum values â†' AccountType enum values (mirrors server-side mapping)
|
||||||
const subTypeToAccountType = {
|
const subTypeToAccountType = {
|
||||||
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
|
8: 1, 1: 1, 2: 1, 3: 1, 4: 1, 5: 1, 6: 1, 7: 1, // Assets
|
||||||
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
|
10: 2, 11: 2, 12: 2, 13: 2, // Liabilities
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
|
@model PowderCoating.Application.DTOs.Accounting.EditAccountDto
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Edit Account";
|
ViewData["Title"] = "Edit Account";
|
||||||
ViewData["PageIcon"] = "bi-pencil-square";
|
ViewData["PageIcon"] = "bi-pencil-square";
|
||||||
ViewData["PageHelpTitle"] = "Edit Account";
|
ViewData["PageHelpTitle"] = "Edit Account";
|
||||||
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
|
ViewData["PageHelpContent"] = "You can change number, name, type, sub-type, parent, and opening balance. Changing the account type or sub-type on an account that already has transactions is allowed but use caution — it changes how balances are reported going forward. Inactive accounts are hidden from pickers but preserved in history.";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-start mb-4">
|
<div class="d-flex justify-content-start mb-4">
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Account Number"
|
data-bs-title="Account Number"
|
||||||
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique.">
|
data-bs-content="A numeric code for sorting and organizing accounts. Convention: 1000–1999 Assets, 2000–2999 Liabilities, 3000–3999 Equity, 4000–4999 Revenue, 5000–5999 Cost of Goods, 6000–9999 Expenses. Must be unique.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -81,12 +81,12 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Parent Account"
|
data-bs-title="Parent Account"
|
||||||
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
data-bs-content="Nest this account under a parent to create a hierarchy — e.g. 'Powder Costs' under 'Cost of Goods Sold'. Sub-accounts roll up into their parent on financial reports. Most accounts work fine without a parent.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
<select asp-for="ParentAccountId" asp-items="ViewBag.ParentAccounts" class="form-select">
|
||||||
<option value="">— None —</option>
|
<option value="">— None —</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@* Bootstrap toast — confirmation before recalculating balances *@
|
@* Bootstrap toast — confirmation before recalculating balances *@
|
||||||
<div class="toast-container position-fixed top-50 start-50 translate-middle p-3" style="z-index:1100">
|
<div class="toast-container position-fixed top-50 start-50 translate-middle p-3" style="z-index:1100">
|
||||||
<div id="recalcConfirmToast" class="toast align-items-center border-0 bg-dark text-white" role="alert" aria-atomic="true" data-bs-autohide="false">
|
<div id="recalcConfirmToast" class="toast align-items-center border-0 bg-dark text-white" role="alert" aria-atomic="true" data-bs-autohide="false">
|
||||||
<div class="toast-body d-flex flex-column gap-2 py-3 px-3">
|
<div class="toast-body d-flex flex-column gap-2 py-3 px-3">
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
<span class="fw-medium">@acct.Name</span>
|
<span class="fw-medium">@acct.Name</span>
|
||||||
@if (acct.IsSystem)
|
@if (acct.IsSystem)
|
||||||
{
|
{
|
||||||
<span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
|
<span class="badge bg-secondary ms-1" title="System account — cannot be deleted">sys</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td><span class="text-muted small">@acct.AccountSubType.ToDisplayName()</span></td>
|
<td><span class="text-muted small">@acct.AccountSubType.ToDisplayName()</span></td>
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
@if (!acct.IsSystem)
|
@if (!acct.IsSystem)
|
||||||
{
|
{
|
||||||
<form asp-action="Delete" asp-route-id="@acct.Id" method="post" class="d-inline"
|
<form asp-action="Delete" asp-route-id="@acct.Id" method="post" class="d-inline"
|
||||||
onsubmit="return confirm('Delete account @acct.AccountNumber – @acct.Name?')">
|
onsubmit="return confirm('Delete account @acct.AccountNumber – @acct.Name?')">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
<button type="submit" class="btn btn-sm btn-outline-danger" title="Delete">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Recalculate Balances — show confirmation toast instead of native confirm()
|
// Recalculate Balances — show confirmation toast instead of native confirm()
|
||||||
const recalcToast = new bootstrap.Toast(document.getElementById('recalcConfirmToast'));
|
const recalcToast = new bootstrap.Toast(document.getElementById('recalcConfirmToast'));
|
||||||
|
|
||||||
document.getElementById('btnRecalcBalances').addEventListener('click', () => {
|
document.getElementById('btnRecalcBalances').addEventListener('click', () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@using PowderCoating.Core.Enums
|
@using PowderCoating.Core.Enums
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = $"Ledger — {Model.AccountNumber} {Model.Name}";
|
ViewData["Title"] = $"Ledger — {Model.AccountNumber} {Model.Name}";
|
||||||
ViewData["PageIcon"] = "bi-journal-text";
|
ViewData["PageIcon"] = "bi-journal-text";
|
||||||
ViewData["PageHelpTitle"] = "Account Ledger";
|
ViewData["PageHelpTitle"] = "Account Ledger";
|
||||||
ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view.";
|
ViewData["PageHelpContent"] = "A chronological list of every transaction posted to this account. Click any Reference to open the source record. Debit increases asset and expense accounts; credit increases liability, equity, and revenue accounts. Use the date range or quick buttons (This Month, YTD, etc.) to narrow the view.";
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
<nav aria-label="breadcrumb" class="mb-3">
|
<nav aria-label="breadcrumb" class="mb-3">
|
||||||
<ol class="breadcrumb">
|
<ol class="breadcrumb">
|
||||||
<li class="breadcrumb-item"><a asp-action="Index">Chart of Accounts</a></li>
|
<li class="breadcrumb-item"><a asp-action="Index">Chart of Accounts</a></li>
|
||||||
<li class="breadcrumb-item active">@Model.AccountNumber – @Model.Name</li>
|
<li class="breadcrumb-item active">@Model.AccountNumber – @Model.Name</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
<i class="bi bi-journal-text me-1"></i>
|
<i class="bi bi-journal-text me-1"></i>
|
||||||
Transactions
|
Transactions
|
||||||
<span class="text-muted fw-normal small ms-1">
|
<span class="text-muted fw-normal small ms-1">
|
||||||
@Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy")
|
@Model.From.ToString("MMM d") – @Model.To.ToString("MMM d, yyyy")
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
<!-- Opening balance row -->
|
<!-- Opening balance row -->
|
||||||
<tr class="table-light">
|
<tr class="table-light">
|
||||||
<td class="text-muted small">@Model.From.ToString("MM/dd/yyyy")</td>
|
<td class="text-muted small">@Model.From.ToString("MM/dd/yyyy")</td>
|
||||||
<td><span class="fw-medium text-muted">—</span></td>
|
<td><span class="fw-medium text-muted">—</span></td>
|
||||||
<td><span class="badge bg-dark-subtle text-dark">Opening Balance</span></td>
|
<td><span class="badge bg-dark-subtle text-dark">Opening Balance</span></td>
|
||||||
<td class="text-muted small">Balance brought forward as of @Model.From.ToString("MMM d, yyyy")</td>
|
<td class="text-muted small">Balance brought forward as of @Model.From.ToString("MMM d, yyyy")</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
@using PowderCoating.Core.Entities
|
@using PowderCoating.Core.Entities
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Year-End Close";
|
ViewData["Title"] = "Year-End Close";
|
||||||
ViewData["PageIcon"] = "bi-calendar-check";
|
ViewData["PageIcon"] = "bi-calendar-check";
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="alert alert-warning alert-permanent py-2 mb-4">
|
<div class="alert alert-warning alert-permanent py-2 mb-4">
|
||||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||||
<strong>What this does:</strong> Posts a Journal Entry dated December 31 that zeroes all Revenue
|
<strong>What this does:</strong> Posts a Journal Entry dated December 31 that zeroes all Revenue
|
||||||
and Expense account balances into Retained Earnings — the standard accounting close.
|
and Expense account balances into Retained Earnings — the standard accounting close.
|
||||||
Run this <strong>after</strong> all entries for the year are posted and the period is locked.
|
Run this <strong>after</strong> all entries for the year are posted and the period is locked.
|
||||||
A year can only be closed once.
|
A year can only be closed once.
|
||||||
</div>
|
</div>
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="fw-bold">@c.ClosedYear</td>
|
<td class="fw-bold">@c.ClosedYear</td>
|
||||||
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
|
<td>@c.ClosedAt.ToLocalTime().ToString("MM/dd/yyyy h:mm tt")</td>
|
||||||
<td>@(c.ClosedBy ?? "—")</td>
|
<td>@Html.Raw(c.ClosedBy ?? "—")</td>
|
||||||
<td>
|
<td>
|
||||||
@if (c.JournalEntry != null)
|
@if (c.JournalEntry != null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fs-3 fw-bold">@Model.TotalCallsLast30Days.ToString("N0")</div>
|
<div class="fs-3 fw-bold">@Model.TotalCallsLast30Days.ToString("N0")</div>
|
||||||
<div class="text-muted small">AI Calls — Last 30 Days</div>
|
<div class="text-muted small">AI Calls — Last 30 Days</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,11 +96,11 @@
|
|||||||
<!-- Tier Legend -->
|
<!-- Tier Legend -->
|
||||||
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
|
<div class="d-flex flex-wrap gap-2 mb-3 align-items-center">
|
||||||
<span class="text-muted small fw-semibold me-1">Usage Tier (last 30 days):</span>
|
<span class="text-muted small fw-semibold me-1">Usage Tier (last 30 days):</span>
|
||||||
<span class="badge bg-secondary">Inactive — 0 calls</span>
|
<span class="badge bg-secondary">Inactive — 0 calls</span>
|
||||||
<span class="badge bg-success">Light — 1–10</span>
|
<span class="badge bg-success">Light — 1–10</span>
|
||||||
<span class="badge bg-primary">Regular — 11–50</span>
|
<span class="badge bg-primary">Regular — 11–50</span>
|
||||||
<span class="badge bg-warning text-dark">Heavy — 51–200</span>
|
<span class="badge bg-warning text-dark">Heavy — 51–200</span>
|
||||||
<span class="badge bg-danger">Power User — 200+</span>
|
<span class="badge bg-danger">Power User — 200+</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Table -->
|
<!-- Main Table -->
|
||||||
@@ -207,16 +207,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-center @(row.Today > 0 ? "fw-semibold" : "text-muted")">
|
||||||
@(row.Today > 0 ? row.Today.ToString("N0") : "—")
|
@Html.Raw(row.Today > 0 ? row.Today.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-center @(row.Last7Days > 0 ? "fw-semibold" : "text-muted")">
|
||||||
@(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "—")
|
@Html.Raw(row.Last7Days > 0 ? row.Last7Days.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
<td class="text-center @(row.Last30Days > 0 ? "fw-semibold" : "text-muted")">
|
||||||
@(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "—")
|
@Html.Raw(row.Last30Days > 0 ? row.Last30Days.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
|
<td class="text-center @(row.AllTime > 0 ? "" : "text-muted")">
|
||||||
@(row.AllTime > 0 ? row.AllTime.ToString("N0") : "—")
|
@Html.Raw(row.AllTime > 0 ? row.AllTime.ToString("N0") : "—")
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
|
<td class="text-center @(row.PhotoCount > 0 ? "" : "text-muted")">
|
||||||
@if (row.PhotoCount > 0)
|
@if (row.PhotoCount > 0)
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span>—</span>
|
<span>—</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -244,7 +244,7 @@
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
<span class="text-muted">—</span>
|
<span class="text-muted">—</span>
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td class="text-center">
|
<td class="text-center">
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
onclick="window.location='@Url.Action("Edit", new { id = a.Id })'">
|
onclick="window.location='@Url.Action("Edit", new { id = a.Id })'">
|
||||||
<td>
|
<td>
|
||||||
<div class="fw-medium">@a.Title</div>
|
<div class="fw-medium">@a.Title</div>
|
||||||
<small class="text-muted">@a.Message.Substring(0, Math.Min(60, a.Message.Length))@(a.Message.Length > 60 ? "…" : "")</small>
|
<small class="text-muted">@a.Message.Substring(0, Math.Min(60, a.Message.Length))@(a.Message.Length > 60 ? "…" : "")</small>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="badge @TypeBadge(a.Type)">@a.Type</span></td>
|
<td><span class="badge @TypeBadge(a.Type)">@a.Type</span></td>
|
||||||
<td>
|
<td>
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<!-- Mobile card view — shown on screens < 992px -->
|
<!-- Mobile card view — shown on screens < 992px -->
|
||||||
<div class="mobile-card-view">
|
<div class="mobile-card-view">
|
||||||
<div class="mobile-card-list">
|
<div class="mobile-card-list">
|
||||||
@if (!Model.Any())
|
@if (!Model.Any())
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<label class="form-label fw-medium">Message <span class="text-danger">*</span></label>
|
<label class="form-label fw-medium">Message <span class="text-danger">*</span></label>
|
||||||
<textarea asp-for="Message" class="form-control" rows="3"
|
<textarea asp-for="Message" class="form-control" rows="3"
|
||||||
placeholder="The platform will be offline for maintenance on Saturday from 2–4 AM ET." required></textarea>
|
placeholder="The platform will be offline for maintenance on Saturday from 2–4 AM ET." required></textarea>
|
||||||
<span asp-validation-for="Message" class="text-danger small"></span>
|
<span asp-validation-for="Message" class="text-danger small"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
<div id="planTargetGroup" style="display:none">
|
<div id="planTargetGroup" style="display:none">
|
||||||
<label class="form-label fw-medium">Plan</label>
|
<label class="form-label fw-medium">Plan</label>
|
||||||
<select asp-for="TargetPlan" class="form-select">
|
<select asp-for="TargetPlan" class="form-select">
|
||||||
<option value="">— select —</option>
|
<option value="">— select —</option>
|
||||||
@foreach (var p in planConfigs)
|
@foreach (var p in planConfigs)
|
||||||
{
|
{
|
||||||
<option value="@p.Plan">@p.DisplayName</option>
|
<option value="@p.Plan">@p.DisplayName</option>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<div id="companyTargetGroup" style="display:none">
|
<div id="companyTargetGroup" style="display:none">
|
||||||
<label class="form-label fw-medium">Company</label>
|
<label class="form-label fw-medium">Company</label>
|
||||||
<select asp-for="TargetCompanyId" class="form-select">
|
<select asp-for="TargetCompanyId" class="form-select">
|
||||||
<option value="">— select —</option>
|
<option value="">— select —</option>
|
||||||
@foreach (var c in companies)
|
@foreach (var c in companies)
|
||||||
{
|
{
|
||||||
<option value="@c.Id">@c.CompanyName</option>
|
<option value="@c.Id">@c.CompanyName</option>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
<label class="form-label fw-medium text-muted">Preview</label>
|
<label class="form-label fw-medium text-muted">Preview</label>
|
||||||
<div id="announcementPreview" class="alert mb-0" role="alert">
|
<div id="announcementPreview" class="alert mb-0" role="alert">
|
||||||
<strong id="previewTitle">@Model.Title</strong>
|
<strong id="previewTitle">@Model.Title</strong>
|
||||||
<span id="previewMessage"> — @Model.Message</span>
|
<span id="previewMessage"> — @Model.Message</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
const preview = document.getElementById('announcementPreview');
|
const preview = document.getElementById('announcementPreview');
|
||||||
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
|
preview.className = 'alert mb-0 ' + (typeMap[type] || 'alert-info');
|
||||||
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
|
document.getElementById('previewTitle').textContent = document.getElementById('Title').value || 'Title';
|
||||||
document.getElementById('previewMessage').textContent = ' — ' + (document.getElementById('Message').value || 'Message');
|
document.getElementById('previewMessage').textContent = ' — ' + (document.getElementById('Message').value || 'Message');
|
||||||
}
|
}
|
||||||
document.getElementById('Type')?.addEventListener('change', updatePreview);
|
document.getElementById('Type')?.addEventListener('change', updatePreview);
|
||||||
document.getElementById('Title')?.addEventListener('input', updatePreview);
|
document.getElementById('Title')?.addEventListener('input', updatePreview);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
|
@model PowderCoating.Application.DTOs.Appointment.CreateAppointmentDto
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "New Appointment";
|
ViewData["Title"] = "New Appointment";
|
||||||
ViewData["PageIcon"] = "bi-calendar-plus";
|
ViewData["PageIcon"] = "bi-calendar-plus";
|
||||||
ViewData["PageHelpTitle"] = "New Appointment";
|
ViewData["PageHelpTitle"] = "New Appointment";
|
||||||
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
|
ViewData["PageHelpContent"] = "Create an appointment to schedule a customer visit, drop-off, pick-up, or consultation. Select the Type first — the Linked Job field appears once a type is chosen. Reminder notifications fire before the scheduled start time.";
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-end align-items-center mb-4">
|
<div class="d-flex justify-content-end align-items-center mb-4">
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<a tabindex="0" class="help-icon" role="button"
|
<a tabindex="0" class="help-icon" role="button"
|
||||||
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
data-bs-toggle="popover" data-bs-placement="right" data-bs-trigger="focus"
|
||||||
data-bs-title="Reminder Settings"
|
data-bs-title="Reminder Settings"
|
||||||
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
data-bs-content="Enable a reminder to receive an in-app notification before the appointment. Set how many minutes in advance — e.g., 30 for a brief heads-up, 1440 for a full day before. Reminders are per-appointment and do not send external emails or SMS.">
|
||||||
<i class="bi bi-question-circle"></i>
|
<i class="bi bi-question-circle"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user