Merge duplicate powder lines on dashboard order queue

When multiple jobs need the same powder, the 'Powder in Queue to be
Ordered' panel now collapses them into a single line (summed lbs) rather
than showing one row per coat. 'Mark as Ordered' marks all contributing
coats at once and injects each into the 'Awaiting Receipt' panel
individually so per-coat receiving still works unchanged.

- Add PowderOrderJobRefDto; PowderOrderLineDto gains CoatIds + Jobs lists
  (scalar CoatId/JobId/etc. become computed accessors for backward compat)
- MapPowderOrderGroupsMerged: secondary GroupBy on (ColorName, ColorCode,
  Finish, SKU) within vendor group for the 'needed' panel
- MapPowderOrderGroups kept per-coat for the 'awaiting receipt' panel
- MarkPowderOrdered accepts comma-separated coatIds, returns coats array
- Dashboard view: Customer column loops job refs for merged rows; JS posts
  coatIds and iterates data.coats to populate awaiting-receipt panel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:59:10 -04:00
parent a21c05f655
commit 8f11e00a0a
4 changed files with 282 additions and 525 deletions
+122 -479
View File
@@ -1,134 +1,72 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Guidance for Claude Code when working 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).
ASP.NET Core 8.0 MVC application for powder coating business operations. Clean Architecture:
**Core** (entities/interfaces) → **Application** (DTOs/profiles) → **Infrastructure** (EF/repos/services) + **Web** (Razor MVC) + **Api** (REST/JWT).
## Essential Commands
### Building and Running
```bash
# Build entire solution
# Build
dotnet build
# Run web application (MVC)
cd src/PowderCoating.Web
dotnet run
# Access at: https://localhost:58461
# Web MVC — https://localhost:58461
cd src/PowderCoating.Web && dotnet run
# Run web with auto-reload
dotnet watch run
# API — Swagger at root URL
cd src/PowderCoating.Api && dotnet 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
# Tests
dotnet test
dotnet test tests/PowderCoating.UnitTests
dotnet test tests/PowderCoating.IntegrationTests
```
### Database Operations
### Database (EF Core)
Run from `src/PowderCoating.Web`. **Always include `--context ApplicationDbContext`** — multiple DbContexts exist; omitting it throws.
```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
dotnet ef migrations add <Name> --project ../PowderCoating.Infrastructure --context ApplicationDbContext
dotnet ef database update --project ../PowderCoating.Infrastructure --context ApplicationDbContext
dotnet ef migrations remove --project ../PowderCoating.Infrastructure --context ApplicationDbContext
dotnet ef migrations list --project ../PowderCoating.Infrastructure --context ApplicationDbContext
dotnet ef database drop --project ../PowderCoating.Infrastructure --context ApplicationDbContext
```
### 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!
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
## Architecture
### Clean Architecture Layers
### Layers
- **Core** — Entities, enums, repository + service interfaces. `BaseEntity` provides `Id`, `CompanyId`, `CreatedAt`, `UpdatedAt`, `IsDeleted`, audit fields on every entity.
- **Application** — DTOs, AutoMapper profiles (auto-discovered via `cfg.AddMaps()`; `PricingTierProfile` is an exception — registered manually in `Program.cs`), service interfaces. No UI/infra deps.
- **Infrastructure** — `ApplicationDbContext`, `Repository<T>`, `UnitOfWork`. Seed data is **manual only** via Platform Management → Seed Data.
- **Web** — Razor MVC + Bootstrap 5. **Api** — JWT Bearer, Swagger.
**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
### Global Query Filters (always active)
- Soft deletes: `IsDeleted == false`
- Multi-tenancy: non-SuperAdmin sees only their `CompanyId`
- Bypass: `ignoreQueryFilters: true` on repository methods
**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)
**Critical:** global filters are not sufficient on their own. Every `FindAsync`/`GetAllAsync` in a controller must also include an explicit `CompanyId == currentCompanyId` predicate — defense in depth.
## 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`
> All data access goes through `IUnitOfWork`. Enforced at startup by `EnforceDataAccessArchitecture()` in `Program.cs`.
> Full rationale + permanent exceptions: `docs/DATA_ACCESS_ARCHITECTURE.md`
### Three tiers — use the right one:
@@ -141,346 +79,57 @@ 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/`
Typed repos: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, `ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`.
**Tier 3 — Aggregate/reporting queries** → injected read services
**Tier 3 — Aggregate/reporting** → 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/`
Services: `IFinancialReportService`, `IOperationalReportService`.
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
### Permanent exceptions (ApplicationDbContext allowed — intentional):
`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.
---
## Domain Concepts
### Job Lifecycle
16 statuses in `JobStatusLookup` **table — NOT an enum**: Pending → Quoted → Approved → InPreparation → Sandblasting → MaskingTaping → Cleaning → InOven → Coating → Curing → QualityCheck → Completed → ReadyForPickup → Delivered | OnHold | Cancelled.
Use `.Include(j => j.JobStatus)` and filter on `!j.JobStatus.IsTerminalStatus`.
**Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI).
### Customers
- **Commercial**: B2B, pricing tiers, credit limits
- **Non-Commercial**: individual/residential
### Inventory
Transactions tracked in `InventoryTransaction` (Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial). Reorder points trigger alerts.
### Equipment & Maintenance
Equipment: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired.
Maintenance priority: Low/Normal/High/Critical. Status: Scheduled/InProgress/Completed/Cancelled/Overdue.
---
## Data Access Patterns
## Pricing
### 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 Claude Sonnet 4.6** (`claude-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 Claude 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 ★
### Key Rules
- Custom powder (no inventory item + `PowderToOrder > 0`): charge for the **full ordered quantity**
- In-stock powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
- Tax-exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote/invoice create; marked ★ in dropdowns
### 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.**
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes via boolean flags. **Must exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem |
|------|------------------------------|
@@ -489,83 +138,77 @@ All modules below are fully implemented with controllers, views, and migrations
| `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 13 are done — this is intentional
**Checklist when adding a new flag:**
1. Add to `QuoteItem` (Core/Entities)
2. Add to `JobItem` (Core/Entities)
3. Add to `CreateQuoteItemDto` (Application/DTOs)
4. Add to `JobItemSeed` (private class in `JobItemAssemblyService`)
5. Map in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include in every `existingItemsData` JSON block in `Edit.cshtml`, `EditItems.cshtml`, and all controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add migration if field is new on a persisted entity
8. Structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` fails until steps 13 are done — 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
## Configuration
- Entity Framework warnings about global query filters on related entities (non-critical, informational only)
### Key Settings (`src/PowderCoating.Web/appsettings.json`)
- DB: `ConnectionStrings:DefaultConnection` (SQL Server Express)
- AI: `AI:Anthropic:ApiKey`**Anthropic Claude `claude-sonnet-4-6`, NOT OpenAI**
- Ports: HTTPS 58461 / HTTP 58462
## File Upload Configuration
### Auth & Roles
- Web: cookie-based ASP.NET Identity. API: JWT Bearer.
- System roles: SuperAdmin, Administrator, Manager, Employee, ShopFloor, ReadOnly
- Policies in `AppConstants.cs`: `RequireAdministratorRole`, `CanManageJobs`, `CanManageInventory`, `CanManageUsers`, `CanViewData`, `CompanyAdminOnly`
- **PricingTiers use `CompanyAdminOnly` — NOT `RequireAdministratorRole`** (that policy is unregistered and will throw)
Limits defined in `AppConstants.cs`:
- Max file size: 10 MB
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
### File Uploads
Limits in `AppConstants.cs`: 10 MB max, allowed: 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
## UI Rules
## Extending the System
- **HTML entities in `.cshtml`** — `&mdash;` not `—`, `&times;` not `×`, `&hellip;` not `…`. Literal Unicode gets corrupted by AI tools + Windows file encoding.
- **External JS files only** — put scripts in `wwwroot/js/*.js`, reference via `src=`. Inline `@section Scripts` blocks can silently fail with SyntaxErrors from layout HTML context.
- **`alert-permanent` CSS class** — `_Layout` auto-dismisses `.alert:not(.alert-permanent)` after ~5s. Any non-toast alert that must persist needs this class.
- **SignalR hubs already in place**: `NotificationHub``/hubs/notifications` (company-scoped), `ShopHub``/hubs/shop` (shop floor).
### Adding AI Features
---
AI uses Anthropic Claude 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
## Gotchas
### SignalR Hubs
- **Two data export controllers**: `DataExportController` (SuperAdmin) and `AccountDataExportController` (company self-service). When changing CSV columns, fix **both**.
- **Help docs**: when a feature changes, update both `HelpKnowledgeBase.cs` (AI assistant knowledge) and the matching article in `Views/Help/` (human-readable help center).
- **Demo reset**: `DemoController.ResetDemoData` is gated on `company.CompanyCode == "DEMO"` — only the demo tenant can trigger a reset. ForceRemoveAll wipes all company data before reseeding.
- **artemis@ account**: the "break glass" root SuperAdmin — guards in `PlatformUsersController` protecting it are intentional, never remove them.
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
## Implemented Modules
### Adding API Endpoints
All fully implemented with controllers, views, and migrations applied.
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
**Operations**: Jobs (16 statuses, worker assignment, time entries, rework, shop codes, templates) · Quotes (AI Photo Quoting via Anthropic, quote→job conversion, customer approval portal) · Invoices (1:1 Job→Invoice unique index; partial payments, void, PDF, email) · Deposits (auto-applied on invoice create; QuestPDF receipt) · Customers (commercial/non-commercial, pricing tiers, tax-exempt + cert upload) · Oven Scheduler (named ovens, capacity, suggested batches)
## Project Dependencies
**Inventory & Purchasing**: Inventory (transactions, reorder alerts, powder coverage/efficiency) · Vendors · Purchase Orders (create/submit/receive, convert to bills) · Accounts Payable (bills, AP ledger, payment tracking)
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)
**Shop Management**: Shop Workers (roles, job/maintenance assignment) · Equipment & Maintenance · Catalog Items · Pricing Tiers
## Security Considerations
**Billing**: Stripe (subscriptions, checkout sessions, webhooks `/stripe/webhook`) · Stripe Connect (embedded payments, OAuth) · Twilio SMS (`ISmsService`; webhook `POST /Webhooks/TwilioSms`)
- 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
**Platform (SuperAdmin)**: Platform Users · Companies · Seed Data (manual only) · Subscription Plans (`SubscriptionPlanConfig`)
## 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/CLAUDE.md` for that work.
**Other**: Help Center (14 articles at `Views/Help/`) · Setup Wizard (10-step, `SetupWizardController`) · Reports (24 actions: P&L, AR Aging, Powder Usage, Cycle Time, PDF exports) · Gift Certificates · Announcements · In-App Notification Bell · Passkey/Biometric Login (WebAuthn, Fido2NetLib) · Customer Intake Kiosk (iPad, SignalR push, `KioskSession`) · AI Accounting Features (receipt scan, AR follow-up, smart categorization, cash flow forecast, anomaly detection)
---
## Branding
- App name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — sidebar header (when no tenant logo), login/register, sidebar footer (always)
- Sidebar footer always links to `http://www.powdercoatinglogix.com`
- Tenant logos: Azure Blob `companylogos` container; replaces PCL logo in sidebar header only
## Active Design Work
A visual redesign is in progress. For UI changes, dashboard/jobs/board styling, or design tokens: read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/CLAUDE.md`.
@@ -228,12 +228,31 @@ public class PowderOrderVendorGroupDto
public List<PowderOrderLineDto> Lines { get; set; } = new();
}
public class PowderOrderLineDto
/// <summary>
/// One job's contribution to a merged powder order line.
/// </summary>
public class PowderOrderJobRefDto
{
public int CoatId { get; set; }
public int JobId { get; set; }
public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty;
public decimal LbsToOrder { get; set; }
}
public class PowderOrderLineDto
{
/// <summary>All coat IDs contributing to this line (&gt;1 when multiple jobs need the same powder).</summary>
public List<int> CoatIds { get; set; } = new();
/// <summary>Per-job breakdown; parallel to CoatIds.</summary>
public List<PowderOrderJobRefDto> Jobs { get; set; } = new();
// Convenience accessors for single-coat scenarios (the "placed" panel is always per-coat).
public int CoatId => CoatIds.FirstOrDefault();
public int JobId => Jobs.FirstOrDefault()?.JobId ?? 0;
public string JobNumber => Jobs.FirstOrDefault()?.JobNumber ?? string.Empty;
public string CustomerName => Jobs.FirstOrDefault()?.CustomerName ?? string.Empty;
public string CoatName { get; set; } = string.Empty;
public string? ColorName { get; set; }
public string? ColorCode { get; set; }
@@ -266,7 +266,7 @@ public class DashboardController : Controller
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded);
var powderOrderGroups = MapPowderOrderGroupsMerged(data.PowderOrdersNeeded);
// ---------------------------------------------------------------
// Powder orders placed
@@ -385,44 +385,53 @@ public class DashboardController : Controller
}
/// <summary>
/// Marks a job-item coat as having its powder ordered. Called via AJAX from the Powder Orders
/// Needed panel. Verifies company ownership through the parent job (JobItemCoat has no direct
/// CompanyId) before updating <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and
/// <c>PowderOrderedByUserId</c> on the coat record.
/// Marks one or more job-item coats as having their powder ordered. Called via AJAX from
/// the "Powder in Queue to be Ordered" panel. Accepts a comma-separated list of coat IDs
/// so that a merged line (multiple jobs needing the same powder) can be marked in one click.
/// Verifies company ownership for each coat via its parent job before updating
/// <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and <c>PowderOrderedByUserId</c>.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> MarkPowderOrdered(int coatId)
public async Task<IActionResult> MarkPowderOrdered(string coatIds)
{
try
{
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
var ids = coatIds?
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : 0)
.Where(id => id > 0)
.ToList() ?? new List<int>();
if (coat == null)
return Json(new { success = false, message = "Coat not found." });
if (ids.Count == 0)
return Json(new { success = false, message = "No valid coat IDs provided." });
// JobItemCoat has no CompanyId — verify ownership via parent job
var parentCompanyId = coat.JobItem?.Job?.CompanyId;
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var results = new List<object>();
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await _unitOfWork.CompleteAsync();
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
return Json(new
foreach (var coatId in ids)
{
success = true,
coat = new
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
if (coat == null) continue;
// JobItemCoat has no CompanyId — verify ownership via parent job
var parentCompanyId = coat.JobItem?.Job?.CompanyId;
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
continue;
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = currentUserId;
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
results.Add(new
{
coatId = coat.Id,
jobId = job?.Id,
jobNumber = job?.JobNumber,
customerName = job?.Customer?.CompanyName,
customerName = job?.Customer?.CompanyName ?? job?.Customer?.ContactFirstName ?? "Unknown",
colorName = coat.ColorName,
colorCode = coat.ColorCode,
finish = coat.Finish,
@@ -434,12 +443,16 @@ public class DashboardController : Controller
vendorId = vendor?.Id,
vendorName = vendor?.CompanyName ?? "No Vendor Assigned",
vendorPhone = vendor?.Phone
}
});
});
}
await _unitOfWork.CompleteAsync();
return Json(new { success = true, coats = results });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error marking coat {CoatId} as powder ordered", coatId);
_logger.LogError(ex, "Error marking coats {CoatIds} as powder ordered", coatIds);
return Json(new { success = false, message = "An error occurred." });
}
}
@@ -876,6 +889,10 @@ public class DashboardController : Controller
}
}
/// <summary>
/// Projects per-coat rows into vendor-grouped DTOs one-line-per-coat.
/// Used for the "Awaiting Receipt" panel where each coat is received individually.
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
@@ -892,10 +909,11 @@ public class DashboardController : Controller
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = g.Select(l => new PowderOrderLineDto
{
CoatId = l.CoatId,
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
CoatIds = new List<int> { l.CoatId },
Jobs = new List<PowderOrderJobRefDto>
{
new() { JobId = l.JobId, JobNumber = l.JobNumber, CustomerName = l.CustomerName, LbsToOrder = l.LbsToOrder }
},
CoatName = l.CoatName,
ColorName = l.ColorName,
ColorCode = l.ColorCode,
@@ -916,6 +934,68 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Like <see cref="MapPowderOrderGroups"/> but collapses coats for the same powder within
/// a vendor group into one line, summing lbs and accumulating coat IDs and job refs.
/// Used for the "Powder in Queue to be Ordered" panel so you order one batch per color.
/// Two coats are considered the same powder when ColorName, ColorCode, Finish, and SKU
/// all match (case- and whitespace-insensitive).
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroupsMerged(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(vendorGrp =>
{
var first = vendorGrp.First();
var mergedLines = vendorGrp
.GroupBy(l => (
ColorName: l.ColorName?.Trim().ToLowerInvariant() ?? "",
ColorCode: l.ColorCode?.Trim().ToLowerInvariant() ?? "",
Finish: l.Finish?.Trim().ToLowerInvariant() ?? "",
SKU: l.SKU?.Trim().ToLowerInvariant() ?? ""
))
.Select(powderGrp =>
{
var p = powderGrp.First();
return new PowderOrderLineDto
{
CoatIds = powderGrp.Select(l => l.CoatId).ToList(),
Jobs = powderGrp.Select(l => new PowderOrderJobRefDto
{
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
LbsToOrder = l.LbsToOrder
}).ToList(),
CoatName = p.CoatName,
ColorName = p.ColorName,
ColorCode = p.ColorCode,
Finish = p.Finish,
SKU = p.SKU,
LbsToOrder = powderGrp.Sum(l => l.LbsToOrder),
CostPerLb = p.CostPerLb,
HasInventoryItem = p.HasInventoryItem,
VendorId = p.VendorId
};
})
.OrderBy(l => l.ColorName)
.ThenBy(l => l.CoatName)
.ToList();
return new PowderOrderVendorGroupDto
{
VendorId = vendorGrp.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = vendorGrp.Sum(l => l.LbsToOrder),
TotalEstCost = vendorGrp.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = mergedLines
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary>
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
@@ -528,11 +528,25 @@
<tbody>
@foreach (var line in vendorGroup.Lines)
{
<tr id="powder-line-@line.CoatId">
<tr id="powder-line-@line.CoatIds[0]">
<td>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@line.JobId"
class="fw-medium text-decoration-none">@line.CustomerName</a>
<span class="text-muted ms-1">(@line.JobNumber)</span>
@if (line.Jobs.Count == 1)
{
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@line.Jobs[0].JobId"
class="fw-medium text-decoration-none">@line.Jobs[0].CustomerName</a>
<span class="text-muted ms-1">(@line.Jobs[0].JobNumber)</span>
}
else
{
@foreach (var jobRef in line.Jobs)
{
<div>
<a asp-controller="Jobs" asp-action="Details" asp-route-id="@jobRef.JobId"
class="fw-medium text-decoration-none">@jobRef.CustomerName</a>
<span class="text-muted ms-1">(@jobRef.JobNumber &middot; @jobRef.LbsToOrder.ToString("N2") lbs)</span>
</div>
}
}
</td>
<td>
@if (!string.IsNullOrEmpty(line.ColorName))
@@ -551,7 +565,7 @@
</td>
<td class="text-center">
<button class="btn btn-sm btn-outline-danger mark-ordered-btn text-nowrap"
data-coat-id="@line.CoatId"
data-coat-ids="@string.Join(",", line.CoatIds)"
title="Mark as ordered">
<i class="bi bi-check2-circle me-1"></i>Mark as Ordered
</button>
@@ -875,10 +889,11 @@
// Powder Orders - Mark as Ordered
document.querySelectorAll('.mark-ordered-btn').forEach(btn => {
btn.addEventListener('click', async function () {
const coatId = this.dataset.coatId;
const row = document.getElementById('powder-line-' + coatId);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
const coatIds = this.dataset.coatIds;
const firstId = coatIds.split(',')[0];
const row = document.getElementById('powder-line-' + firstId);
const token = document.querySelector('input[name="__RequestVerificationToken"]')?.value
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
this.disabled = true;
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
@@ -887,7 +902,7 @@
const resp = await fetch('@Url.Action("MarkPowderOrdered", "Dashboard")', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': token },
body: 'coatId=' + coatId
body: 'coatIds=' + encodeURIComponent(coatIds)
});
const data = await resp.json();
if (data.success) {
@@ -905,8 +920,8 @@
badge.textContent = n;
}
}
// Inject into Awaiting Receipt widget
addToAwaitingReceipt(data.coat);
// Inject each marked coat into the Awaiting Receipt widget
(data.coats || []).forEach(c => addToAwaitingReceipt(c));
}, 400);
} else {
alert(data.message || 'Could not update. Please try again.');