Compare commits
96 Commits
4650ba3d4d
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c4625ba28a | |||
| 9c1beab49e | |||
| aeec899cf2 | |||
| 54defc158f | |||
| 8f11e00a0a | |||
| a21c05f655 | |||
| 1e5510477a | |||
| 6eb7be0193 | |||
| 7735fe3cce | |||
| 249128e852 | |||
| c0e4a66126 | |||
| dbd39a9fe5 | |||
| 584664e7c8 | |||
| 1255bc0670 | |||
| 01f6897d08 | |||
| 72382a5dd5 | |||
| 86a293a927 | |||
| 35264e6b2a | |||
| 0b839d0746 | |||
| 66c3febd7a | |||
| b8057295ec | |||
| 14d6c82839 | |||
| db4b73013a | |||
| e313149f08 | |||
| 82fb48f7a5 | |||
| 427c52a499 | |||
| d92266b027 | |||
| 750e1b1c5b | |||
| 94a89ee175 | |||
| 711cd01cd3 | |||
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 | |||
| 03b425a12f | |||
| 8453449833 | |||
| ad986561c9 | |||
| 0d5553f3b2 | |||
| 87bbf158a4 | |||
| f453a95f28 | |||
| d9e98a55d2 | |||
| 99deca3b62 | |||
| 23e64829bb | |||
| cd4c233b60 | |||
| 6c07216c64 | |||
| b23bea6db0 | |||
| cf07356147 | |||
| 39b103a482 | |||
| 4aae2df5b5 | |||
| 3416c242f1 | |||
| 7e31846777 | |||
| ed35362c7a | |||
| 81119035c7 | |||
| 0deef574c3 | |||
| efc4e9dadf | |||
| ca7e905832 | |||
| 32d09b38f1 | |||
| 3cee1307fc | |||
| be89327c01 | |||
| 8f955851e5 | |||
| 972123c7a2 | |||
| 9dd36238bb | |||
| 8ae61b6c78 | |||
| 97745f9a65 | |||
| e124fd5c8b | |||
| 6c2fe6e1c4 | |||
| f625be01a3 | |||
| e6c4cfb38b | |||
| 5b5247624c | |||
| 91a176ce5c | |||
| a7ad0e1de8 | |||
| e4a256a6c4 | |||
| e476b4744d | |||
| 04d16109ae | |||
| f0f3717681 | |||
| e23b006139 | |||
| 0f35946973 | |||
| 19e1ce858f | |||
| 026e646295 | |||
| b7fcefa765 | |||
| 1722cd4124 | |||
| c3742e1585 | |||
| 1a6f855c05 | |||
| d28e639d1b | |||
| 10f668fd73 | |||
| 19b7a9a473 | |||
| b7ab85ff92 | |||
| ce7b00b68c | |||
| c5c1244177 | |||
| 25140554ad | |||
| 46cadea367 | |||
| cfe937c0c3 | |||
| 3ad6b0d08f | |||
| fdac0240d1 |
@@ -1,134 +1,72 @@
|
|||||||
# CLAUDE.md
|
# 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
|
## 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
|
## Essential Commands
|
||||||
|
|
||||||
### Building and Running
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Build entire solution
|
# Build
|
||||||
dotnet build
|
dotnet build
|
||||||
|
|
||||||
# Run web application (MVC)
|
# Web MVC — https://localhost:58461
|
||||||
cd src/PowderCoating.Web
|
cd src/PowderCoating.Web && dotnet run
|
||||||
dotnet run
|
|
||||||
# Access at: https://localhost:58461
|
|
||||||
|
|
||||||
# Run web with auto-reload
|
# API — Swagger at root URL
|
||||||
dotnet watch run
|
cd src/PowderCoating.Api && dotnet run
|
||||||
|
|
||||||
# Run API
|
# Tests
|
||||||
cd src/PowderCoating.Api
|
dotnet test
|
||||||
dotnet run
|
dotnet test tests/PowderCoating.UnitTests
|
||||||
# Swagger UI at root URL
|
dotnet test tests/PowderCoating.IntegrationTests
|
||||||
|
|
||||||
# Run tests
|
|
||||||
dotnet test # All tests
|
|
||||||
dotnet test tests/PowderCoating.UnitTests # Unit tests only
|
|
||||||
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Database Operations
|
### Database (EF Core)
|
||||||
|
|
||||||
|
Run from `src/PowderCoating.Web`. **Always include `--context ApplicationDbContext`** — multiple DbContexts exist; omitting it throws.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# All EF commands run from Web project directory
|
|
||||||
cd src/PowderCoating.Web
|
cd src/PowderCoating.Web
|
||||||
|
dotnet ef migrations add <Name> --project ../PowderCoating.Infrastructure --context ApplicationDbContext
|
||||||
# Create migration (must specify Infrastructure project)
|
dotnet ef database update --project ../PowderCoating.Infrastructure --context ApplicationDbContext
|
||||||
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure
|
dotnet ef migrations remove --project ../PowderCoating.Infrastructure --context ApplicationDbContext
|
||||||
|
dotnet ef migrations list --project ../PowderCoating.Infrastructure --context ApplicationDbContext
|
||||||
# Apply migrations
|
dotnet ef database drop --project ../PowderCoating.Infrastructure --context ApplicationDbContext
|
||||||
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
|
### Default Credentials
|
||||||
|
|
||||||
```
|
```
|
||||||
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
|
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
|
||||||
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
|
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
|
||||||
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
|
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
|
||||||
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
|
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)**
|
### Global Query Filters (always active)
|
||||||
- Contains business entities, enums, and repository interfaces
|
- Soft deletes: `IsDeleted == false`
|
||||||
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields)
|
- Multi-tenancy: non-SuperAdmin sees only their `CompanyId`
|
||||||
- All entities inherit from BaseEntity and support soft delete
|
- Bypass: `ignoreQueryFilters: true` on repository methods
|
||||||
- No dependencies on other projects
|
|
||||||
|
|
||||||
**Application Layer (PowderCoating.Application)**
|
**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.
|
||||||
- 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)
|
## Data Access Rules (ENFORCE THESE)
|
||||||
|
|
||||||
> **`ApplicationDbContext` is NEVER injected into a controller.**
|
> **`ApplicationDbContext` is NEVER injected into a controller.**
|
||||||
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below.
|
> All data access goes through `IUnitOfWork`. Enforced at startup by `EnforceDataAccessArchitecture()` in `Program.cs`.
|
||||||
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all
|
> Full rationale + permanent exceptions: `docs/DATA_ACCESS_ARCHITECTURE.md`
|
||||||
> 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:
|
### Three tiers — use the right one:
|
||||||
|
|
||||||
@@ -141,346 +79,57 @@ await _unitOfWork.CompleteAsync();
|
|||||||
|
|
||||||
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
|
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
|
||||||
```csharp
|
```csharp
|
||||||
// Include chains and domain-specific queries belong in the repository, not the controller
|
|
||||||
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
|
||||||
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
|
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
|
||||||
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
|
||||||
```
|
```
|
||||||
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`,
|
Typed repos: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, `ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
|
||||||
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
|
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`.
|
||||||
— 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
|
```csharp
|
||||||
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
|
|
||||||
var aging = await _financialReports.GetArAgingAsync(companyId);
|
var aging = await _financialReports.GetArAgingAsync(companyId);
|
||||||
```
|
```
|
||||||
Services: `IFinancialReportService`, `IOperationalReportService`
|
Services: `IFinancialReportService`, `IOperationalReportService`.
|
||||||
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
|
|
||||||
|
|
||||||
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented):
|
### Permanent exceptions (ApplicationDbContext allowed — intentional):
|
||||||
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
|
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
|
||||||
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
|
`DataExportController`, `AccountDataExportController`, `DataPurgeController`,
|
||||||
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
|
`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
|
### Key Rules
|
||||||
|
- Custom powder (no inventory item + `PowderToOrder > 0`): charge for the **full ordered quantity**
|
||||||
```csharp
|
- In-stock powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
|
||||||
public class ExampleController : Controller
|
- Tax-exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote/invoice create; marked ★ in dropdowns
|
||||||
{
|
|
||||||
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 ★
|
|
||||||
|
|
||||||
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
|
### 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 |
|
| 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 |
|
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||||
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||||
|
|
||||||
**Checklist when adding a new pricing routing flag:**
|
**Checklist when adding a new flag:**
|
||||||
1. Add the property to `QuoteItem` (Core/Entities)
|
1. Add to `QuoteItem` (Core/Entities)
|
||||||
2. Add the property to `JobItem` (Core/Entities)
|
2. Add to `JobItem` (Core/Entities)
|
||||||
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
3. Add to `CreateQuoteItemDto` (Application/DTOs)
|
||||||
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
4. Add to `JobItemSeed` (private class in `JobItemAssemblyService`)
|
||||||
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
5. Map 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`
|
6. Include in every `existingItemsData` JSON block in `Edit.cshtml`, `EditItems.cshtml`, and all controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||||
7. Add a migration if the field is new on a persisted entity
|
7. Add migration if 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
|
8. Structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` fails until steps 1–3 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`:
|
### File Uploads
|
||||||
- Max file size: 10 MB
|
Limits in `AppConstants.cs`: 10 MB max, allowed: jpg/jpeg/png/gif/pdf/doc/docx/xls/xlsx.
|
||||||
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
|
|
||||||
|
|
||||||
## Testing Strategy
|
---
|
||||||
|
|
||||||
- **Unit Tests**: Test business logic in isolation
|
## UI Rules
|
||||||
- **Integration Tests**: Test full request pipeline with test database
|
|
||||||
- Use xUnit framework
|
|
||||||
- Mock `IUnitOfWork` in unit tests
|
|
||||||
|
|
||||||
## Extending the System
|
- **HTML entities in `.cshtml`** — `—` not `—`, `×` not `×`, `…` 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`.
|
## Gotchas
|
||||||
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 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:
|
## Implemented Modules
|
||||||
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
|
All fully implemented with controllers, views, and migrations applied.
|
||||||
|
|
||||||
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute
|
**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)
|
||||||
2. Return `ActionResult<T>` types
|
|
||||||
3. Use `[Authorize]` for protected endpoints
|
|
||||||
4. Document with XML comments for Swagger
|
|
||||||
|
|
||||||
## 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:
|
**Shop Management**: Shop Workers (roles, job/maintenance assignment) · Equipment & Maintenance · Catalog Items · Pricing Tiers
|
||||||
- **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
|
**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
|
**Platform (SuperAdmin)**: Platform Users · Companies · Seed Data (manual only) · Subscription Plans (`SubscriptionPlanConfig`)
|
||||||
- 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
|
**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)
|
||||||
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.
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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`.
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<!--
|
||||||
|
NCalc2 2.1.0 -> Antlr4 4.6.4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 pulls in
|
||||||
|
old package versions that trigger NU1605 downgrade warnings when publishing for linux-x64.
|
||||||
|
These are harmless false positives — .NET 8 supplies all of these natively at runtime.
|
||||||
|
Suppressing NU1605 here is cleaner than pinning every affected transitive package individually.
|
||||||
|
-->
|
||||||
|
<NoWarn>$(NoWarn);NU1605</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
TRACKED: System.Security.Cryptography.Xml 8.0.2 has two High advisories (GHSA-37gx-xxp4-5rgx,
|
||||||
|
GHSA-w3x6-4m5h-cxqf — XML signature vulnerabilities). No patched version exists in the NuGet
|
||||||
|
feed as of 2026-06-14; 9.0.0 (the only higher version) is also flagged. Re-check when a
|
||||||
|
patched 8.x or 9.x build ships and pin here. Pulled in transitively by one of: Fido2, EPPlus,
|
||||||
|
Azure SDK, or VisualStudio.Web.CodeGeneration.Design.
|
||||||
|
-->
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- Company Data Purge Script
|
||||||
|
-- Removes financial, job, and quote data dated before a cutoff date.
|
||||||
|
-- Customers and Vendors are always preserved.
|
||||||
|
--
|
||||||
|
-- WHAT THIS DELETES (using each entity's own business date, not CreatedAt):
|
||||||
|
-- • Journal entries (EntryDate) & bank reconciliations (StatementDate)
|
||||||
|
-- • Bills (BillDate), bill payments (PaymentDate), purchase orders (OrderDate)
|
||||||
|
-- • Vendor credits (CreditDate), expenses (Date)
|
||||||
|
-- • Invoices (InvoiceDate), payments (PaymentDate), deposits (ReceivedDate)
|
||||||
|
-- • Credit memos, refunds, gift cert redemptions tied to deleted invoices
|
||||||
|
-- • Jobs (IntakeDate, falling back to CreatedAt when null) and all child records
|
||||||
|
-- • Quotes (QuoteDate) and all child records
|
||||||
|
--
|
||||||
|
-- WHAT THIS KEEPS (always):
|
||||||
|
-- • Customers, customer notes, customer contacts, preferred powders
|
||||||
|
-- • Vendors
|
||||||
|
-- • Inventory items and inventory transactions
|
||||||
|
-- • Equipment, catalog items, pricing tiers
|
||||||
|
-- • Company settings and configuration
|
||||||
|
-- • Any record whose business date >= @CutoffDate
|
||||||
|
--
|
||||||
|
-- INSTRUCTIONS:
|
||||||
|
-- 1. Set @CompanyId — find it with: SELECT Id, Name FROM Companies
|
||||||
|
-- 2. Set @CutoffDate — records dated BEFORE this date are deleted
|
||||||
|
-- 3. Run with @DryRun = 1 first and review the row counts printed
|
||||||
|
-- 4. Back up the database before setting @DryRun = 0
|
||||||
|
-- 5. Set @DryRun = 0 and run again to apply
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
DECLARE @CutoffDate DATE = '2026-01-01'; -- Delete records dated BEFORE this date
|
||||||
|
DECLARE @CompanyId INT = 0; -- !! Set to your company ID before running
|
||||||
|
DECLARE @DryRun BIT = 1; -- 1 = preview counts only | 0 = apply deletes
|
||||||
|
|
||||||
|
-- =============================================================================
|
||||||
|
SET NOCOUNT ON;
|
||||||
|
|
||||||
|
IF @CompanyId = 0
|
||||||
|
BEGIN
|
||||||
|
RAISERROR('ERROR: Set @CompanyId before running this script. Run: SELECT Id, Name FROM Companies', 16, 1);
|
||||||
|
RETURN;
|
||||||
|
END
|
||||||
|
|
||||||
|
PRINT '============================================================';
|
||||||
|
PRINT 'Purge run at: ' + CONVERT(NVARCHAR, GETDATE(), 120);
|
||||||
|
PRINT 'Company ID : ' + CAST(@CompanyId AS NVARCHAR);
|
||||||
|
PRINT 'Cutoff date : ' + CAST(@CutoffDate AS NVARCHAR) + ' (records BEFORE this date)';
|
||||||
|
PRINT 'Dry run : ' + CASE @DryRun WHEN 1 THEN 'YES — no changes will be made' ELSE 'NO — deletes will be applied' END;
|
||||||
|
PRINT '============================================================';
|
||||||
|
|
||||||
|
BEGIN TRANSACTION;
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- SECTION 1 — JOURNAL ENTRIES & GL
|
||||||
|
-- Uses EntryDate (JournalEntries) and StatementDate (BankReconciliations)
|
||||||
|
-- ===========================================================================
|
||||||
|
PRINT '';
|
||||||
|
PRINT '--- Section 1: Journal Entries & GL ---';
|
||||||
|
|
||||||
|
-- Null the self-referential ReversalOfId FK before deleting
|
||||||
|
UPDATE JournalEntries
|
||||||
|
SET ReversalOfId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND ReversalOfId IN (SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CAST(EntryDate AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
DELETE FROM JournalEntryLines
|
||||||
|
WHERE JournalEntryId IN (
|
||||||
|
SELECT Id FROM JournalEntries WHERE CompanyId = @CompanyId AND CAST(EntryDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JournalEntryLines deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JournalEntries
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(EntryDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'JournalEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM BankReconciliations
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(StatementDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'BankReconciliations deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- SECTION 2 — BILLS, PURCHASE ORDERS & EXPENSES
|
||||||
|
-- Uses CreditDate (VendorCredits), BillDate (Bills), OrderDate (POs), Date (Expenses)
|
||||||
|
-- ===========================================================================
|
||||||
|
PRINT '';
|
||||||
|
PRINT '--- Section 2: Bills, Purchase Orders & Expenses ---';
|
||||||
|
|
||||||
|
-- Vendor credits (must come before Bills because VendorCreditApplications references both)
|
||||||
|
DELETE FROM VendorCreditApplications
|
||||||
|
WHERE VendorCreditId IN (
|
||||||
|
SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CAST(CreditDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'VendorCreditApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM VendorCreditLineItems
|
||||||
|
WHERE VendorCreditId IN (
|
||||||
|
SELECT Id FROM VendorCredits WHERE CompanyId = @CompanyId AND CAST(CreditDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'VendorCreditLineItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM VendorCredits
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(CreditDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'VendorCredits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Bills
|
||||||
|
DELETE FROM BillPayments
|
||||||
|
WHERE BillId IN (
|
||||||
|
SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CAST(BillDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'BillPayments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM BillLineItems
|
||||||
|
WHERE BillId IN (
|
||||||
|
SELECT Id FROM Bills WHERE CompanyId = @CompanyId AND CAST(BillDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'BillLineItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM Bills
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(BillDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'Bills deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Purchase orders
|
||||||
|
DELETE FROM PurchaseOrderItems
|
||||||
|
WHERE PurchaseOrderId IN (
|
||||||
|
SELECT Id FROM PurchaseOrders WHERE CompanyId = @CompanyId AND CAST(OrderDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'PurchaseOrderItems deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM PurchaseOrders
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(OrderDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'PurchaseOrders deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Expenses (Date column)
|
||||||
|
DELETE FROM Expenses
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST([Date] AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'Expenses deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- SECTION 3 — INVOICES, PAYMENTS & DEPOSITS
|
||||||
|
-- Uses InvoiceDate (Invoices), PaymentDate (Payments), ReceivedDate (Deposits)
|
||||||
|
-- CreditMemos/Refunds/GiftCertRedemptions have no standalone date — deleted
|
||||||
|
-- only when their parent invoice falls within the cutoff.
|
||||||
|
-- ===========================================================================
|
||||||
|
PRINT '';
|
||||||
|
PRINT '--- Section 3: Invoices, Payments & Deposits ---';
|
||||||
|
|
||||||
|
-- CreditMemos: NULL the OriginalInvoiceId FK before deleting the invoice it points to
|
||||||
|
UPDATE CreditMemos
|
||||||
|
SET OriginalInvoiceId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND OriginalInvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
DELETE FROM CreditMemoApplications
|
||||||
|
WHERE InvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate)
|
||||||
|
OR CreditMemoId IN (
|
||||||
|
SELECT Id FROM CreditMemos WHERE CompanyId = @CompanyId AND CAST(CreatedAt AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'CreditMemoApplications deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM CreditMemos
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(CreatedAt AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'CreditMemos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Refunds and gift-cert redemptions tied to deleted invoices
|
||||||
|
DELETE FROM Refunds
|
||||||
|
WHERE InvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'Refunds deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM GiftCertificateRedemptions
|
||||||
|
WHERE InvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'GiftCertificateRedemptions deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Payments (PaymentDate)
|
||||||
|
DELETE FROM Payments
|
||||||
|
WHERE InvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'Payments deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- InvoiceItems (NULL SourceJobItemId on any invoice items that survive but point at deleted jobs)
|
||||||
|
UPDATE InvoiceItems
|
||||||
|
SET SourceJobItemId = NULL
|
||||||
|
WHERE SourceJobItemId IN (
|
||||||
|
SELECT ji.Id FROM JobItems ji
|
||||||
|
INNER JOIN Jobs j ON ji.JobId = j.Id
|
||||||
|
WHERE j.CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(j.IntakeDate, j.CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
DELETE FROM InvoiceItems
|
||||||
|
WHERE InvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'InvoiceItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Deposits: clear the AppliedToInvoiceId FK before deleting the invoices
|
||||||
|
UPDATE Deposits
|
||||||
|
SET AppliedToInvoiceId = NULL,
|
||||||
|
AppliedDate = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND AppliedToInvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
-- Now delete deposits that fall within the cutoff (ReceivedDate)
|
||||||
|
DELETE FROM Deposits
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(ReceivedDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'Deposits deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Notification logs referencing deleted invoices
|
||||||
|
UPDATE NotificationLogs
|
||||||
|
SET InvoiceId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND InvoiceId IN (
|
||||||
|
SELECT Id FROM Invoices WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
DELETE FROM Invoices
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(InvoiceDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'Invoices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- SECTION 4 — JOBS
|
||||||
|
-- Uses COALESCE(IntakeDate, CreatedAt) — IntakeDate is the business date;
|
||||||
|
-- falls back to CreatedAt when not set (e.g. jobs created before IntakeDate existed).
|
||||||
|
-- ===========================================================================
|
||||||
|
PRINT '';
|
||||||
|
PRINT '--- Section 4: Jobs ---';
|
||||||
|
|
||||||
|
-- NULL FKs in other tables that point to jobs/job-items being deleted --
|
||||||
|
|
||||||
|
-- BillLineItems.JobId (bill survived but referenced a deleted job)
|
||||||
|
UPDATE BillLineItems
|
||||||
|
SET JobId = NULL
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
-- Expenses.JobId
|
||||||
|
UPDATE Expenses
|
||||||
|
SET JobId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
-- Appointments.JobId
|
||||||
|
UPDATE Appointments
|
||||||
|
SET JobId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
-- Deposits.JobId (NoAction FK — must NULL before deleting job)
|
||||||
|
UPDATE Deposits
|
||||||
|
SET JobId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
-- NotificationLogs.JobId
|
||||||
|
UPDATE NotificationLogs
|
||||||
|
SET JobId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
-- OvenBatchItems: delete before OvenBatches and before JobItems
|
||||||
|
DELETE FROM OvenBatchItems
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'OvenBatchItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Clean up now-empty OvenBatches (batches belonging to this company with no remaining items)
|
||||||
|
DELETE FROM OvenBatches
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND Id NOT IN (SELECT DISTINCT OvenBatchId FROM OvenBatchItems);
|
||||||
|
PRINT 'OvenBatches deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- ReworkRecords (JobId required FK; ReworkJobId optional)
|
||||||
|
DELETE FROM ReworkRecords
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate)
|
||||||
|
OR ReworkJobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'ReworkRecords deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- JobItem children (coats and prep services)
|
||||||
|
DELETE FROM JobItemCoats
|
||||||
|
WHERE JobItemId IN (
|
||||||
|
SELECT Id FROM JobItems WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate));
|
||||||
|
PRINT 'JobItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JobItemPrepServices
|
||||||
|
WHERE JobItemId IN (
|
||||||
|
SELECT Id FROM JobItems WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate));
|
||||||
|
PRINT 'JobItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JobItems
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JobItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- Job metadata tables
|
||||||
|
DELETE FROM JobChangeHistories
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JobChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JobTimeEntries
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JobTimeEntries deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JobPhotos
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JobPhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JobNotes
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JobNotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JobStatusHistory
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JobStatusHistory deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM JobDailyPriorities
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'JobDailyPriorities deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM PowderUsageLogs
|
||||||
|
WHERE JobId IN (
|
||||||
|
SELECT Id FROM Jobs WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'PowderUsageLogs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM AiItemPredictions
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(CreatedAt AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'AiItemPredictions deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM Jobs
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND CAST(COALESCE(IntakeDate, CreatedAt) AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'Jobs deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- SECTION 5 — QUOTES
|
||||||
|
-- Uses QuoteDate
|
||||||
|
-- ===========================================================================
|
||||||
|
PRINT '';
|
||||||
|
PRINT '--- Section 5: Quotes ---';
|
||||||
|
|
||||||
|
-- NULL FKs that point to quotes being deleted
|
||||||
|
UPDATE Deposits
|
||||||
|
SET QuoteId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
UPDATE NotificationLogs
|
||||||
|
SET QuoteId = NULL
|
||||||
|
WHERE CompanyId = @CompanyId
|
||||||
|
AND QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||||
|
|
||||||
|
-- QuoteItem children
|
||||||
|
DELETE FROM QuoteItemCoats
|
||||||
|
WHERE QuoteItemId IN (
|
||||||
|
SELECT Id FROM QuoteItems WHERE QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate));
|
||||||
|
PRINT 'QuoteItemCoats deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM QuoteItemPrepServices
|
||||||
|
WHERE QuoteItemId IN (
|
||||||
|
SELECT Id FROM QuoteItems WHERE QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate));
|
||||||
|
PRINT 'QuoteItemPrepServices deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM QuoteItems
|
||||||
|
WHERE QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'QuoteItems deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM QuoteChangeHistories
|
||||||
|
WHERE QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'QuoteChangeHistories deleted: ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM QuotePhotos
|
||||||
|
WHERE QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'QuotePhotos deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM QuotePrepServices
|
||||||
|
WHERE QuoteId IN (
|
||||||
|
SELECT Id FROM Quotes WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate);
|
||||||
|
PRINT 'QuotePrepServices deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
DELETE FROM Quotes
|
||||||
|
WHERE CompanyId = @CompanyId AND CAST(QuoteDate AS DATE) < @CutoffDate;
|
||||||
|
PRINT 'Quotes deleted : ' + CAST(@@ROWCOUNT AS NVARCHAR);
|
||||||
|
|
||||||
|
-- ===========================================================================
|
||||||
|
-- SUMMARY & COMMIT / ROLLBACK
|
||||||
|
-- ===========================================================================
|
||||||
|
PRINT '';
|
||||||
|
PRINT '============================================================';
|
||||||
|
|
||||||
|
IF @DryRun = 1
|
||||||
|
BEGIN
|
||||||
|
PRINT 'DRY RUN complete — rolling back. No data was changed.';
|
||||||
|
PRINT 'Set @DryRun = 0 and run again to apply the deletes.';
|
||||||
|
ROLLBACK TRANSACTION;
|
||||||
|
END
|
||||||
|
ELSE
|
||||||
|
BEGIN
|
||||||
|
PRINT 'Purge complete — committing.';
|
||||||
|
COMMIT TRANSACTION;
|
||||||
|
END
|
||||||
@@ -44,6 +44,20 @@ namespace PowderCoating.Application.DTOs.Company
|
|||||||
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
||||||
public bool HasCurrentSmsAgreement { get; set; }
|
public bool HasCurrentSmsAgreement { get; set; }
|
||||||
public string SmsTermsVersion { get; set; } = string.Empty;
|
public string SmsTermsVersion { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Timeclock settings
|
||||||
|
public bool TimeclockEnabled { get; set; }
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
|
||||||
|
public class UpdateTimeclockSettingsDto
|
||||||
|
{
|
||||||
|
public bool TimeclockEnabled { get; set; }
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||||
|
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
namespace PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
// ── Browse / card display ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Lean DTO for the community library browse grid card.</summary>
|
||||||
|
public class FormulaLibraryCardDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? IndustryHint { get; set; }
|
||||||
|
public string SourceCompanyName { get; set; } = string.Empty;
|
||||||
|
public int ImportCount { get; set; }
|
||||||
|
public DateTime SharedAt { get; set; }
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Non-null when this formula was derived from another library entry.</summary>
|
||||||
|
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||||
|
public string? InspiredByName { get; set; }
|
||||||
|
public string? InspiredByCompanyName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when the current company has already imported this entry.</summary>
|
||||||
|
public bool AlreadyImported { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True when this formula was shared by the current browsing company.</summary>
|
||||||
|
public bool IsOwnFormula { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total thumbs-up votes across all companies.</summary>
|
||||||
|
public int ThumbsUp { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total thumbs-down votes across all companies.</summary>
|
||||||
|
public int ThumbsDown { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The current browsing company's vote: true = up, false = down, null = no vote.</summary>
|
||||||
|
public bool? MyVote { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Full detail (import preview modal) ────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Full DTO used in the import preview modal — shows fields and formula.</summary>
|
||||||
|
public class FormulaLibraryDetailDto : FormulaLibraryCardDto
|
||||||
|
{
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public int FieldCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share from Company Settings ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Submitted when a company admin shares one of their templates to the community library.</summary>
|
||||||
|
public class ShareFormulaRequest
|
||||||
|
{
|
||||||
|
public int CustomItemTemplateId { get; set; }
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
public string? IndustryHint { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Company Settings list view ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Status of a template relative to the community library, shown in Company Settings.</summary>
|
||||||
|
public class FormulaLibraryStatusDto
|
||||||
|
{
|
||||||
|
/// <summary>The FormulaLibraryItem Id, if this template has ever been shared.</summary>
|
||||||
|
public int? LibraryItemId { get; set; }
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether this template is eligible to be shared (original or modified import).</summary>
|
||||||
|
public bool CanShare { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Set when this template was imported; the name of the original library entry.</summary>
|
||||||
|
public string? ImportedFromName { get; set; }
|
||||||
|
public string? ImportedFromCompany { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Customer;
|
||||||
|
|
||||||
|
public class CustomerContactDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
public string? Title { get; set; }
|
||||||
|
public string? ContactRole { get; set; }
|
||||||
|
public string? Email { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
public string? MobilePhone { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public string DisplayName => string.IsNullOrWhiteSpace(LastName) ? FirstName : $"{FirstName} {LastName}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CreateCustomerContactDto
|
||||||
|
{
|
||||||
|
[Required(ErrorMessage = "First name is required.")]
|
||||||
|
[StringLength(100)]
|
||||||
|
[Display(Name = "First Name")]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
[Display(Name = "Last Name")]
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
[Display(Name = "Job Title")]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
[StringLength(50)]
|
||||||
|
[Display(Name = "Role")]
|
||||||
|
public string? ContactRole { get; set; }
|
||||||
|
|
||||||
|
[EmailAddress]
|
||||||
|
[StringLength(200)]
|
||||||
|
[Display(Name = "Email")]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[Phone]
|
||||||
|
[StringLength(20)]
|
||||||
|
[Display(Name = "Phone")]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[Phone]
|
||||||
|
[StringLength(20)]
|
||||||
|
[Display(Name = "Mobile Phone")]
|
||||||
|
public string? MobilePhone { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
[Display(Name = "Notes")]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class UpdateCustomerContactDto : CreateCustomerContactDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
namespace PowderCoating.Application.DTOs.Customer;
|
||||||
|
|
||||||
|
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
|
||||||
|
public class CustomerTimelineEventDto
|
||||||
|
{
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
public string Icon { get; set; } = string.Empty;
|
||||||
|
public string BadgeColor { get; set; } = string.Empty;
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
public string? Subtitle { get; set; }
|
||||||
|
public decimal? Amount { get; set; }
|
||||||
|
public int? EntityId { get; set; }
|
||||||
|
public string? LinkController { get; set; }
|
||||||
|
public string? LinkAction { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
|
||||||
|
public class CustomerLifetimeStatsDto
|
||||||
|
{
|
||||||
|
public int TotalJobs { get; set; }
|
||||||
|
public int ActiveJobs { get; set; }
|
||||||
|
/// <summary>Sum of Total on non-voided invoices.</summary>
|
||||||
|
public decimal TotalRevenue { get; set; }
|
||||||
|
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
|
||||||
|
public decimal TotalCollected { get; set; }
|
||||||
|
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
|
||||||
|
public decimal AverageJobValue { get; set; }
|
||||||
|
public DateTime? LastJobDate { get; set; }
|
||||||
|
public int? DaysSinceLastJob { get; set; }
|
||||||
|
public int TotalQuotes { get; set; }
|
||||||
|
public int TotalInvoices { get; set; }
|
||||||
|
public decimal OpenBalance { get; set; }
|
||||||
|
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
|
||||||
|
public int? LastJobId { get; set; }
|
||||||
|
}
|
||||||
@@ -36,6 +36,16 @@ public class CustomerDto
|
|||||||
public bool NotifyBySms { get; set; }
|
public bool NotifyBySms { get; set; }
|
||||||
public DateTime? SmsConsentedAt { get; set; }
|
public DateTime? SmsConsentedAt { get; set; }
|
||||||
public string? SmsConsentMethod { get; set; }
|
public string? SmsConsentMethod { get; set; }
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
// Ship-to address
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateCustomerDto : IValidatableObject
|
public class CreateCustomerDto : IValidatableObject
|
||||||
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
|
|||||||
[StringLength(2000)]
|
[StringLength(2000)]
|
||||||
public string? GeneralNotes { get; set; }
|
public string? GeneralNotes { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "How did you find us?")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
// Ship-to / alternate address
|
||||||
|
[Display(Name = "Ship-To Street Address")]
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "City")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "State")]
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Zip Code")]
|
||||||
|
[StringLength(20)]
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Country")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Notify by Email")]
|
[Display(Name = "Notify by Email")]
|
||||||
public bool NotifyByEmail { get; set; } = true;
|
public bool NotifyByEmail { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -228,12 +228,31 @@ public class PowderOrderVendorGroupDto
|
|||||||
public List<PowderOrderLineDto> Lines { get; set; } = new();
|
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 int JobId { get; set; }
|
||||||
public string JobNumber { get; set; } = string.Empty;
|
public string JobNumber { get; set; } = string.Empty;
|
||||||
public string CustomerName { 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 (>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 CoatName { get; set; } = string.Empty;
|
||||||
public string? ColorName { get; set; }
|
public string? ColorName { get; set; }
|
||||||
public string? ColorCode { get; set; }
|
public string? ColorCode { 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; }
|
||||||
|
|||||||
@@ -63,4 +63,22 @@ public class CustomerImportDto
|
|||||||
|
|
||||||
[Name("Notes")]
|
[Name("Notes")]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
[Name("LeadSource")]
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToAddress")]
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToCity")]
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToState")]
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToZipCode")]
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToCountry")]
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class InvoiceImportDto
|
|||||||
[Name("DueDate")]
|
[Name("DueDate")]
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
|
|
||||||
|
[Name("Project Name", "ProjectName")]
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Name("SubTotal")]
|
[Name("SubTotal")]
|
||||||
public decimal SubTotal { get; set; }
|
public decimal SubTotal { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ public class JobImportDto
|
|||||||
[Name("CustomerName")]
|
[Name("CustomerName")]
|
||||||
public string? CustomerName { get; set; }
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
// Optional short label for the job (maps directly to Job.Description).
|
||||||
|
// When blank, the system falls back to SpecialInstructions, then "Imported job".
|
||||||
|
[Name("Description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
[Name("Status")]
|
[Name("Status")]
|
||||||
public string Status { get; set; } = "Pending";
|
public string Status { get; set; } = "Pending";
|
||||||
|
|
||||||
@@ -44,6 +49,9 @@ public class JobImportDto
|
|||||||
[Name("SpecialInstructions")]
|
[Name("SpecialInstructions")]
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
|
|
||||||
|
[Name("ProjectName")]
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Name("Notes")]
|
[Name("Notes")]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ public class QuoteImportDto
|
|||||||
[Name("ExpirationDate")]
|
[Name("ExpirationDate")]
|
||||||
public DateTime? ExpirationDate { get; set; }
|
public DateTime? ExpirationDate { get; set; }
|
||||||
|
|
||||||
|
[Name("ProjectName")]
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Name("Subtotal")]
|
[Name("Subtotal")]
|
||||||
public decimal Subtotal { get; set; }
|
public decimal Subtotal { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? ExternalReference { get; set; }
|
public string? ExternalReference { get; set; }
|
||||||
public int? SalesTaxAccountId { get; set; }
|
public int? SalesTaxAccountId { get; set; }
|
||||||
public string? SalesTaxAccountName { get; set; }
|
public string? SalesTaxAccountName { get; set; }
|
||||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public class JobDto
|
|||||||
public decimal DiscountValue { get; set; }
|
public decimal DiscountValue { get; set; }
|
||||||
public string? DiscountReason { get; set; }
|
public string? DiscountReason { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
@@ -113,6 +114,8 @@ public class JobListDto
|
|||||||
|
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||||
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public DateTime? ScheduledDate { get; set; }
|
public DateTime? ScheduledDate { get; set; }
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
@@ -166,6 +169,7 @@ public class CreateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
@@ -251,6 +255,7 @@ public class UpdateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
@@ -884,4 +887,9 @@ public class QuotePricingResult
|
|||||||
|
|
||||||
// Per-item results (same order as input items)
|
// Per-item results (same order as input items)
|
||||||
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
||||||
|
|
||||||
|
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
|
||||||
|
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
|
||||||
|
public decimal CustomPowderOrderAmount { get; set; }
|
||||||
|
public List<string> CustomPowderOrderColors { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
|
|||||||
public bool AllowAiInventoryAssist { get; set; }
|
public bool AllowAiInventoryAssist { get; set; }
|
||||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||||
public bool AllowSms { get; set; }
|
public bool AllowSms { get; set; }
|
||||||
|
public bool AllowCustomFormulas { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
|
|||||||
public bool AllowAiInventoryAssist { get; set; }
|
public bool AllowAiInventoryAssist { get; set; }
|
||||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||||
public bool AllowSms { get; set; }
|
public bool AllowSms { get; set; }
|
||||||
|
public bool AllowCustomFormulas { get; set; }
|
||||||
|
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Timeclock;
|
||||||
|
|
||||||
|
public class EmployeeClockEntryDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string UserDisplayName { get; set; } = string.Empty;
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public decimal? HoursWorked { get; set; }
|
||||||
|
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsOpen => ClockOutTime == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClockInRequest
|
||||||
|
{
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClockOutRequest
|
||||||
|
{
|
||||||
|
public int EntryId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
|
||||||
|
/// The server determines whether to clock in or clock out based on the employee's open entry.
|
||||||
|
/// </summary>
|
||||||
|
public class KioskPunchRequest
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string Pin { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EditClockEntryRequest
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sent when an employee clicks Break or Lunch to pause their work segment.
|
||||||
|
/// The server closes the current Work entry and opens a Break/Lunch entry.
|
||||||
|
/// </summary>
|
||||||
|
public class GoOnBreakRequest
|
||||||
|
{
|
||||||
|
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
|
||||||
|
public ClockEntryType BreakType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
|
||||||
|
public class ManualEntryRequest
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
|
||||||
|
public class KioskEmployeeDto
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string Initials { get; set; } = string.Empty;
|
||||||
|
/// <summary>True when the employee has an open clock entry right now.</summary>
|
||||||
|
public bool IsClockedIn { get; set; }
|
||||||
|
}
|
||||||
@@ -18,7 +18,8 @@ public class WizardProgressDto
|
|||||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||||
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
||||||
|
|
||||||
public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
|
// Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
|
||||||
|
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
|
||||||
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,4 +19,12 @@ public interface ICustomFormulaAiService
|
|||||||
/// Safe server-side only — no user-controlled code execution.
|
/// Safe server-side only — no user-controlled code execution.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then
|
||||||
|
/// attempts a parse-only evaluation to catch syntax errors before the formula is saved.
|
||||||
|
/// Returns the normalized formula string and a null error on success, or the original
|
||||||
|
/// formula and an error message on failure.
|
||||||
|
/// </summary>
|
||||||
|
(string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the community formula library: sharing, unsharing, importing, and browsing.
|
||||||
|
/// </summary>
|
||||||
|
public interface IFormulaLibraryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all published library entries, with AlreadyImported populated for the given company.
|
||||||
|
/// Optionally filters by search term, output mode, or industry hint.
|
||||||
|
/// </summary>
|
||||||
|
Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
|
||||||
|
int companyId,
|
||||||
|
string? search = null,
|
||||||
|
string? outputMode = null,
|
||||||
|
string? industryHint = null);
|
||||||
|
|
||||||
|
/// <summary>Full detail for the import preview modal, including field list and formula.</summary>
|
||||||
|
Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a company template to the community library.
|
||||||
|
/// If the template was previously shared and unpublished, re-publishes the existing row.
|
||||||
|
/// Updates the library entry fields from the current template state on re-share.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request);
|
||||||
|
|
||||||
|
/// <summary>Sets IsPublished = false. Existing imports are unaffected.</summary>
|
||||||
|
Task UnshareAsync(int libraryItemId, int companyId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies a library entry into the company's local CustomItemTemplate table.
|
||||||
|
/// If the company already has an import record for this entry, returns the existing template id.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> ImportAsync(int libraryItemId, int companyId, string userId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the library status for a given CustomItemTemplate — whether it is shared,
|
||||||
|
/// eligible to be shared, and where it was imported from if applicable.
|
||||||
|
/// </summary>
|
||||||
|
Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
|
||||||
|
/// when a source template's diagram is removed. Call from CompanySettingsController
|
||||||
|
/// when a diagram is deleted or replaced.
|
||||||
|
/// </summary>
|
||||||
|
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records or toggles a thumbs-up/down vote from the given company.
|
||||||
|
/// If the same vote already exists it is removed (toggle off).
|
||||||
|
/// If the opposite vote exists it is replaced.
|
||||||
|
/// Companies cannot rate their own formulas.
|
||||||
|
/// Returns the updated counts for the library entry.
|
||||||
|
/// </summary>
|
||||||
|
Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
||||||
|
int libraryItemId, int companyId, bool isPositive);
|
||||||
|
}
|
||||||
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
|
|||||||
int companyId,
|
int companyId,
|
||||||
decimal? ovenRateOverride,
|
decimal? ovenRateOverride,
|
||||||
DateTime createdAtUtc);
|
DateTime createdAtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
|
||||||
|
/// referenced by coats on the given quote, then links those coats to the new inventory records.
|
||||||
|
/// Must be called after a quote transitions to Approved status.
|
||||||
|
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
|
||||||
|
/// </summary>
|
||||||
|
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,19 @@ public class RemoveSeedDataOptions
|
|||||||
public bool Catalog { get; set; }
|
public bool Catalog { get; set; }
|
||||||
public bool PricingTiers { get; set; }
|
public bool PricingTiers { get; set; }
|
||||||
public bool OperatingCosts { get; set; }
|
public bool OperatingCosts { get; set; }
|
||||||
|
public bool Bills { get; set; }
|
||||||
|
public bool Expenses { get; set; }
|
||||||
|
public bool Workers { get; set; }
|
||||||
|
public bool Vendors { get; set; }
|
||||||
|
public bool NamedOvens { get; set; }
|
||||||
|
public bool Appointments { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, all removal blocks skip fingerprint matching and delete by CompanyId only.
|
||||||
|
/// Use for demo resets where the goal is a full wipe regardless of which code version seeded
|
||||||
|
/// the data. Never set this on a real tenant company.
|
||||||
|
/// </summary>
|
||||||
|
public bool ForceRemoveAll { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeedDataResult
|
public class SeedDataResult
|
||||||
|
|||||||
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
|
|||||||
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
|
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
|
||||||
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
||||||
: string.Empty));
|
: string.Empty));
|
||||||
|
|
||||||
|
// CustomerContact
|
||||||
|
CreateMap<CustomerContact, CustomerContactDto>();
|
||||||
|
CreateMap<CreateCustomerContactDto, CustomerContact>();
|
||||||
|
CreateMap<UpdateCustomerContactDto, CustomerContact>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
|
||||||
|
CreateMap<CustomerContact, UpdateCustomerContactDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Mappings;
|
||||||
|
|
||||||
|
public class FormulaLibraryProfile : Profile
|
||||||
|
{
|
||||||
|
public FormulaLibraryProfile()
|
||||||
|
{
|
||||||
|
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||||
|
.ForMember(dest => dest.InspiredByName,
|
||||||
|
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
|
||||||
|
.ForMember(dest => dest.InspiredByCompanyName,
|
||||||
|
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
|
||||||
|
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
|
||||||
|
|
||||||
|
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
|
||||||
|
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||||
|
.ForMember(dest => dest.FieldCount,
|
||||||
|
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountFields(string fieldsJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||||
|
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||||
|
? doc.RootElement.GetArrayLength()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
|||||||
|
|
||||||
CreateMap<Invoice, InvoiceDto>()
|
CreateMap<Invoice, InvoiceDto>()
|
||||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||||
|
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||||
? (s.Customer.IsCommercial
|
? (s.Customer.IsCommercial
|
||||||
? s.Customer.CompanyName
|
? s.Customer.CompanyName
|
||||||
|
|||||||
@@ -192,7 +192,10 @@ public class QuoteProfile : Profile
|
|||||||
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
||||||
|
|
||||||
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
||||||
|
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
|
||||||
|
// is unreliable for ICollection<T> → List<T2> with different element types.
|
||||||
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
||||||
|
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -98,12 +98,7 @@ public class PdfService : IPdfService
|
|||||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||||
|
|
||||||
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||||
page.Content().Layers(layers =>
|
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||||
{
|
|
||||||
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
|
||||||
if (invoiceDto.Status == InvoiceStatus.Paid)
|
|
||||||
layers.Layer().Element(c => ComposePaidStamp(c));
|
|
||||||
});
|
|
||||||
page.Footer().AlignCenter().Text(text =>
|
page.Footer().AlignCenter().Text(text =>
|
||||||
{
|
{
|
||||||
text.CurrentPageNumber();
|
text.CurrentPageNumber();
|
||||||
@@ -148,8 +143,18 @@ public class PdfService : IPdfService
|
|||||||
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
|
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
|
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
|
||||||
|
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (invoice.Status == InvoiceStatus.Paid)
|
||||||
|
{
|
||||||
|
row.RelativeItem().AlignCenter().AlignMiddle()
|
||||||
|
.Border(2).BorderColor(Colors.Green.Darken1)
|
||||||
|
.PaddingVertical(6).PaddingHorizontal(16)
|
||||||
|
.Text("PAID").FontSize(20).Bold().FontColor(Colors.Green.Darken1).LetterSpacing(0.15f);
|
||||||
|
}
|
||||||
|
|
||||||
row.RelativeItem().AlignRight().Column(column =>
|
row.RelativeItem().AlignRight().Column(column =>
|
||||||
{
|
{
|
||||||
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor);
|
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor);
|
||||||
@@ -165,27 +170,6 @@ public class PdfService : IPdfService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
|
|
||||||
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
|
|
||||||
/// external Skia/SkiaSharp dependency is needed.
|
|
||||||
/// </summary>
|
|
||||||
private static void ComposePaidStamp(IContainer container)
|
|
||||||
{
|
|
||||||
container
|
|
||||||
.AlignCenter()
|
|
||||||
.AlignMiddle()
|
|
||||||
.Rotate(-45f)
|
|
||||||
.Border(5)
|
|
||||||
.BorderColor(Colors.Green.Darken2)
|
|
||||||
.PaddingVertical(14)
|
|
||||||
.PaddingHorizontal(28)
|
|
||||||
.Text("PAID")
|
|
||||||
.FontSize(80)
|
|
||||||
.Bold()
|
|
||||||
.FontColor(Colors.Green.Darken2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
||||||
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
||||||
@@ -217,6 +201,8 @@ public class PdfService : IPdfService
|
|||||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||||
|
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,6 +595,15 @@ public class PdfService : IPdfService
|
|||||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||||
|
{
|
||||||
|
column.Item().Row(row =>
|
||||||
|
{
|
||||||
|
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||||
|
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when a coat requires ordering custom powder that is not in inventory.
|
||||||
|
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
|
||||||
|
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
|
||||||
|
!coat.InventoryItemId.HasValue &&
|
||||||
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
||||||
/// path based on item type:
|
/// path based on item type:
|
||||||
@@ -289,20 +299,22 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
|
// 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.
|
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
|
||||||
|
// exactly like every other item type that uses ManualUnitPrice.
|
||||||
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
|
// 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.
|
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
|
||||||
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
||||||
{
|
{
|
||||||
var total = item.ManualUnitPrice.Value * item.Quantity;
|
var formulaUnitPrice = item.ManualUnitPrice.Value;
|
||||||
|
var formulaTotal = formulaUnitPrice * item.Quantity;
|
||||||
return new QuoteItemPricingResult
|
return new QuoteItemPricingResult
|
||||||
{
|
{
|
||||||
MaterialCost = 0,
|
MaterialCost = 0,
|
||||||
LaborCost = 0,
|
LaborCost = 0,
|
||||||
EquipmentCost = 0,
|
EquipmentCost = 0,
|
||||||
ItemSubtotal = total,
|
ItemSubtotal = formulaTotal,
|
||||||
UnitPrice = item.ManualUnitPrice.Value,
|
UnitPrice = formulaUnitPrice,
|
||||||
TotalPrice = total
|
TotalPrice = formulaTotal
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,6 +342,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
{
|
{
|
||||||
for (int i = 0; i < item.Coats.Count; i++)
|
for (int i = 0; i < item.Coats.Count; i++)
|
||||||
{
|
{
|
||||||
|
// Custom powder material moves to the "Custom Powder Order" line item
|
||||||
|
if (IsCustomPowderCoat(item.Coats[i])) continue;
|
||||||
var coatResult = await CalculateCoatPriceAsync(
|
var coatResult = await CalculateCoatPriceAsync(
|
||||||
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
||||||
coatMaterialCost += coatResult.CoatMaterialCost;
|
coatMaterialCost += coatResult.CoatMaterialCost;
|
||||||
@@ -431,7 +445,9 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
for (int ci = 0; ci < item.Coats.Count; ci++)
|
for (int ci = 0; ci < item.Coats.Count; ci++)
|
||||||
{
|
{
|
||||||
var coat = item.Coats[ci];
|
var coat = item.Coats[ci];
|
||||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
// Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
|
||||||
|
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
|
||||||
|
&& !IsCustomPowderCoat(coat))
|
||||||
{
|
{
|
||||||
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
||||||
totalMaterialCost += coatResult.CoatMaterialCost;
|
totalMaterialCost += coatResult.CoatMaterialCost;
|
||||||
@@ -449,7 +465,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
{
|
{
|
||||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
// Custom powder material moves to the "Custom Powder Order" line item; keep the labor
|
||||||
|
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
|
||||||
coatLaborCost = firstCoatResult.CoatLaborCost;
|
coatLaborCost = firstCoatResult.CoatLaborCost;
|
||||||
totalLaborCost = coatLaborCost;
|
totalLaborCost = coatLaborCost;
|
||||||
}
|
}
|
||||||
@@ -646,6 +663,49 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
// 4. TOTAL ITEMS SUBTOTAL
|
// 4. TOTAL ITEMS SUBTOTAL
|
||||||
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
||||||
|
|
||||||
|
// Powder-to-order costs are excluded from individual item prices and collected in a
|
||||||
|
// "Custom Powder Order" line item added at save time. For live pricing previews (before
|
||||||
|
// save), add them back here so the displayed total stays correct throughout the session.
|
||||||
|
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
|
||||||
|
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
|
||||||
|
bool hasCustomPowderOrderItem = items.Any(i =>
|
||||||
|
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
|
||||||
|
decimal customPowderOrderAmount = 0m;
|
||||||
|
var customPowderOrderColors = new List<string>();
|
||||||
|
if (!hasCustomPowderOrderItem)
|
||||||
|
{
|
||||||
|
foreach (var item in items.Where(i => i.Coats != null))
|
||||||
|
{
|
||||||
|
foreach (var c in item.Coats!)
|
||||||
|
{
|
||||||
|
if (!c.InventoryItemId.HasValue &&
|
||||||
|
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
|
||||||
|
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
|
||||||
|
{
|
||||||
|
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(c.ColorName))
|
||||||
|
customPowderOrderColors.Add(c.ColorName);
|
||||||
|
}
|
||||||
|
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
|
||||||
|
{
|
||||||
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
||||||
|
if (invItem?.IsIncoming == true)
|
||||||
|
{
|
||||||
|
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
|
||||||
|
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
customPowderOrderColors.Add(colorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (customPowderOrderAmount > 0)
|
||||||
|
{
|
||||||
|
itemsSubtotal += customPowderOrderAmount;
|
||||||
|
totalMaterialCosts += customPowderOrderAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
||||||
// AI items already have oven cost baked into their AI-estimated price, so we only
|
// AI items already have oven cost baked into their AI-estimated price, so we only
|
||||||
// charge the proportion of the oven that's attributable to non-AI items.
|
// charge the proportion of the oven that's attributable to non-AI items.
|
||||||
@@ -824,7 +884,11 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
||||||
LaborCosts = Math.Round(totalLaborCosts, 2),
|
LaborCosts = Math.Round(totalLaborCosts, 2),
|
||||||
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
||||||
ItemResults = itemResults
|
ItemResults = itemResults,
|
||||||
|
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
|
||||||
|
CustomPowderOrderColors = customPowderOrderColors
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||||
|
|
||||||
|
var dtoList = itemDtos.ToList();
|
||||||
var items = new List<QuoteItem>();
|
var items = new List<QuoteItem>();
|
||||||
foreach (var itemDto in itemDtos)
|
foreach (var itemDto in dtoList)
|
||||||
{
|
{
|
||||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||||
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
items.Add(item);
|
items.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option B: auto-create the Custom Powder Order item only on first save.
|
||||||
|
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
|
||||||
|
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
|
||||||
|
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
|
||||||
|
if (!hasExistingCustomPowderOrder)
|
||||||
|
{
|
||||||
|
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
|
||||||
|
if (customPowderItem != null)
|
||||||
|
items.Add(customPowderItem);
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
/// 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
|
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
|
||||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
|
||||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
/// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
|
||||||
|
/// quote (deduplication). No inventory is created during quote save.
|
||||||
/// </summary>
|
/// </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)
|
||||||
{
|
{
|
||||||
@@ -183,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
{
|
{
|
||||||
var coatDto = itemDto.Coats[coatIndex];
|
var coatDto = itemDto.Coats[coatIndex];
|
||||||
|
|
||||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
// Incoming-inventory creation is intentionally deferred to quote approval.
|
||||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
// PowderCatalogItemId is persisted on the coat entity for later use.
|
||||||
|
|
||||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||||
@@ -267,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
CoatName = coatDto.CoatName,
|
CoatName = coatDto.CoatName,
|
||||||
Sequence = coatDto.Sequence,
|
Sequence = coatDto.Sequence,
|
||||||
InventoryItemId = coatDto.InventoryItemId,
|
InventoryItemId = coatDto.InventoryItemId,
|
||||||
|
PowderCatalogItemId = coatDto.CatalogItemId,
|
||||||
ColorName = coatDto.ColorName,
|
ColorName = coatDto.ColorName,
|
||||||
VendorId = coatDto.VendorId,
|
VendorId = coatDto.VendorId,
|
||||||
ColorCode = coatDto.ColorCode,
|
ColorCode = coatDto.ColorCode,
|
||||||
@@ -316,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
|
||||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
/// Called at quote-approval time (not during quote save) so inventory records only appear
|
||||||
|
/// when a job is actually going to be created. The caller groups coats by
|
||||||
|
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
|
||||||
|
/// duplicate records when the same powder appears on multiple items in the same quote.
|
||||||
///
|
///
|
||||||
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
|
||||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
/// so the item always lands in the right bucket regardless of how many IsCoating categories
|
||||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
|
||||||
/// 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,
|
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
|
||||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
/// from the manufacturer product page. Best-effort — item is still created from catalog data
|
||||||
/// if it fails, the item is still created with whatever data the catalog has.
|
/// if the AI call fails.
|
||||||
///
|
|
||||||
/// 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>
|
/// </summary>
|
||||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
||||||
if (catalogItem == null) return null;
|
if (catalogItem == null) return null;
|
||||||
|
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||||
var coatingCategory = categories
|
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
|
||||||
.OrderBy(c => c.DisplayOrder)
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
.FirstOrDefault();
|
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories
|
||||||
|
.Where(c => c.IsActive && c.IsCoating)
|
||||||
|
.OrderBy(c => c.DisplayOrder)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||||
@@ -448,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
|
||||||
coatDto.PowderCostPerLb = null;
|
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
|
||||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
item.Id, item.Name, catalogItemId);
|
||||||
item.Id, item.Name, coatDto.CatalogItemId);
|
|
||||||
|
|
||||||
return item.Id;
|
return item.Id;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||||
coatDto.CatalogItemId);
|
catalogItemId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
|
||||||
|
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
|
||||||
|
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
|
||||||
|
/// on the first save only — Option B means the user owns the price after creation.
|
||||||
|
///
|
||||||
|
/// Coat types that qualify:
|
||||||
|
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered)
|
||||||
|
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
|
||||||
|
/// pre-filled from catalog unit price (inventory creation deferred to approval)
|
||||||
|
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
|
||||||
|
/// </summary>
|
||||||
|
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
||||||
|
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
|
||||||
|
{
|
||||||
|
var colorNames = new List<string>();
|
||||||
|
decimal totalCost = 0m;
|
||||||
|
|
||||||
|
foreach (var itemDto in itemDtos)
|
||||||
|
{
|
||||||
|
if (itemDto.Coats == null) continue;
|
||||||
|
foreach (var coat in itemDto.Coats)
|
||||||
|
{
|
||||||
|
if (!coat.InventoryItemId.HasValue &&
|
||||||
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||||
|
{
|
||||||
|
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
|
||||||
|
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
|
||||||
|
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||||
|
colorNames.Add(coat.ColorName);
|
||||||
|
}
|
||||||
|
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
|
{
|
||||||
|
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
|
||||||
|
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
|
||||||
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||||
|
if (invItem?.IsIncoming == true)
|
||||||
|
{
|
||||||
|
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
||||||
|
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
colorNames.Add(colorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCost <= 0) return null;
|
||||||
|
|
||||||
|
var uniqueColors = colorNames
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
var description = uniqueColors.Any()
|
||||||
|
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
||||||
|
: "Custom Powder Order";
|
||||||
|
|
||||||
|
return new QuoteItem
|
||||||
|
{
|
||||||
|
QuoteId = quoteId,
|
||||||
|
Description = description,
|
||||||
|
Quantity = 1,
|
||||||
|
IsGenericItem = true,
|
||||||
|
ManualUnitPrice = totalCost,
|
||||||
|
UnitPrice = totalCost,
|
||||||
|
TotalPrice = totalCost,
|
||||||
|
ItemMaterialCost = totalCost,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = createdAtUtc,
|
||||||
|
Coats = [],
|
||||||
|
PrepServices = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
|
||||||
|
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
|
||||||
|
/// new (or existing) inventory record.
|
||||||
|
///
|
||||||
|
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
|
||||||
|
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
|
||||||
|
/// only reflects powders the shop is actually going to process.
|
||||||
|
///
|
||||||
|
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
|
||||||
|
/// same InventoryItemId — no duplicate records are created.
|
||||||
|
///
|
||||||
|
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
|
||||||
|
/// on an already-approved quote (e.g. retry after a transient error) is safe.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
|
||||||
|
{
|
||||||
|
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
|
||||||
|
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
|
||||||
|
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
|
||||||
|
false,
|
||||||
|
qi => qi.Coats);
|
||||||
|
|
||||||
|
var pendingCoats = quoteItems
|
||||||
|
.SelectMany(qi => qi.Coats)
|
||||||
|
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (pendingCoats.Count == 0) return;
|
||||||
|
|
||||||
|
// Group by catalog item ID so each unique powder generates exactly one inventory record.
|
||||||
|
var groups = pendingCoats
|
||||||
|
.GroupBy(c => c.PowderCatalogItemId!.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
|
||||||
|
if (newInventoryId == null) continue;
|
||||||
|
|
||||||
|
// Link every coat in this group to the single newly-created inventory record.
|
||||||
|
foreach (var coat in group)
|
||||||
|
{
|
||||||
|
coat.InventoryItemId = newInventoryId;
|
||||||
|
coat.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
||||||
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
|
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
|
||||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
/// and the Company Settings live preview (so the UI always shows the same rate
|
||||||
|
/// the AI will use — single formula path, no client-side duplication).
|
||||||
///
|
///
|
||||||
/// Formula:
|
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
|
||||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
/// determines throughput and CFM draw. CFM is not used in the rate formula.
|
||||||
///
|
///
|
||||||
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
|
/// Sources:
|
||||||
/// All multipliers are relative to that baseline.
|
/// Pressure pot rates — averaged from two industry standard abrasive blast
|
||||||
|
/// cleaning reference tables.
|
||||||
|
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
|
||||||
|
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ShopCapabilityCalculator
|
public static class ShopCapabilityCalculator
|
||||||
{
|
{
|
||||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
// ── Public entry points ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr.
|
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
|
||||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
|
||||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (costs.CompressorCfm <= 0)
|
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(costs.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
|
|
||||||
var setup = SetupMultiplier(costs.BlastSetupType);
|
|
||||||
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setup * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
/// Returns the effective blast rate in sqft/hr for a named blast setup.
|
||||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// otherwise derives from the setup's equipment specs.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||||
{
|
{
|
||||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (setup.CompressorCfm <= 0)
|
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(setup.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
|
|
||||||
var setupMult = SetupMultiplier(setup.SetupType);
|
|
||||||
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective coating application rate in sqft/hr.
|
/// Returns the effective coating application rate in sqft/hr.
|
||||||
/// If override is set, returns it directly.
|
/// Override bypasses the formula when set.
|
||||||
/// Otherwise derives a sensible default from gun type.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.CoatingRateSqFtPerHourOverride.Value;
|
return costs.CoatingRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
|
|
||||||
// Without more equipment data (voltage, gun model) we use a single reasonable default.
|
|
||||||
return costs.CoatingGunType switch
|
return costs.CoatingGunType switch
|
||||||
{
|
{
|
||||||
CoatingGunType.Corona => 40m,
|
CoatingGunType.Corona => 40m,
|
||||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
CoatingGunType.Tribo => 35m,
|
||||||
CoatingGunType.Both => 40m,
|
CoatingGunType.Both => 40m,
|
||||||
_ => 40m
|
_ => 40m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns default equipment field values for a given capability tier.
|
/// Returns default equipment field values for a given capability tier, applied
|
||||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
/// during Setup Wizard tier selection so new shops get reasonable starting values.
|
||||||
/// starting values even if they never visit the Quoting Calibration tab.
|
/// CFM defaults reflect typical compressor sizes for each tier; they appear in the
|
||||||
|
/// UI for reference but are not used in the rate formula.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||||
{
|
{
|
||||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
|
||||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Core formula (single path for all callers) ─────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
|
||||||
/// Calibrated so that real-world examples produce expected results:
|
/// setup type routes to the appropriate reference table; substrate adjusts for
|
||||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
|
||||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
/// not an independent variable in throughput.
|
||||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
|
||||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
|
||||||
{
|
{
|
||||||
< 10 => 5m,
|
var baseRate = setupType switch
|
||||||
< 20 => 9m,
|
{
|
||||||
< 40 => 15m,
|
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
|
||||||
< 80 => 25m,
|
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
|
||||||
< 120 => 35m,
|
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
|
||||||
_ => 45m
|
BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
|
||||||
|
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
|
||||||
|
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
|
||||||
|
_ => 0m
|
||||||
|
};
|
||||||
|
|
||||||
|
return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
|
||||||
|
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
|
||||||
|
/// #1 (1/16"): 20-35 sqft/hr avg → 20
|
||||||
|
/// #2 (1/8"): 40-60 sqft/hr avg → 40
|
||||||
|
/// #3 (3/16"): 60-85 sqft/hr avg → 75
|
||||||
|
/// #4 (1/4"): 90-110 sqft/hr avg → 115
|
||||||
|
/// #5 (5/16"): 130-160 sqft/hr avg → 175
|
||||||
|
/// #6 (3/8"): 180-230 sqft/hr avg → 245
|
||||||
|
/// #7 (7/16"): 240-300 sqft/hr avg → 325
|
||||||
|
/// #8 (1/2"): 320-400 sqft/hr avg → 430
|
||||||
|
/// </summary>
|
||||||
|
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
|
||||||
|
{
|
||||||
|
1 => 20m,
|
||||||
|
2 => 40m,
|
||||||
|
3 => 75m,
|
||||||
|
4 => 115m,
|
||||||
|
5 => 175m,
|
||||||
|
6 => 245m,
|
||||||
|
7 => 325m,
|
||||||
|
8 => 430m,
|
||||||
|
_ => 100m
|
||||||
};
|
};
|
||||||
|
|
||||||
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
|
/// <summary>
|
||||||
|
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
|
||||||
|
/// Source: industry reference table for siphon cabinet production rates.
|
||||||
|
/// #1 (1/16"): 10-25 sqft/hr → 18
|
||||||
|
/// #2 (1/8"): 25-50 sqft/hr → 38
|
||||||
|
/// #3 (3/16"): 50-100 sqft/hr → 75
|
||||||
|
/// #4 (1/4"): 100-150 sqft/hr → 125
|
||||||
|
/// #5 (5/16"): 150-225 sqft/hr → 188
|
||||||
|
/// #6 (3/8"): 225-300 sqft/hr → 263
|
||||||
|
/// #7 (7/16"): 300-375 sqft/hr → 338
|
||||||
|
/// #8 (1/2"): 375-450 sqft/hr → 413
|
||||||
|
/// </summary>
|
||||||
|
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
|
||||||
{
|
{
|
||||||
2 => 0.35m,
|
1 => 18m,
|
||||||
3 => 0.55m,
|
2 => 38m,
|
||||||
4 => 0.75m,
|
3 => 75m,
|
||||||
5 => 1.00m,
|
4 => 125m,
|
||||||
6 => 1.30m,
|
5 => 188m,
|
||||||
7 => 1.65m,
|
6 => 263m,
|
||||||
8 => 2.00m,
|
7 => 338m,
|
||||||
_ => 1.00m
|
8 => 413m,
|
||||||
};
|
_ => 80m
|
||||||
|
|
||||||
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
|
|
||||||
{
|
|
||||||
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
|
|
||||||
BlastSetupType.SiphonPot => 0.70m,
|
|
||||||
BlastSetupType.PressurePot => 1.00m, // baseline
|
|
||||||
BlastSetupType.WetBlasting => 0.60m,
|
|
||||||
_ => 1.00m
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
|
||||||
|
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
|
||||||
|
/// </summary>
|
||||||
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
||||||
{
|
{
|
||||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
BlastSubstrateType.PowderCoat => 1.25m,
|
||||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
BlastSubstrateType.Paint => 1.00m,
|
||||||
BlastSubstrateType.Mixed => 0.90m,
|
BlastSubstrateType.Mixed => 0.90m,
|
||||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
BlastSubstrateType.RustAndScale => 0.70m,
|
||||||
_ => 0.90m
|
_ => 0.90m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
|
|||||||
// Passkey enrollment prompt
|
// Passkey enrollment prompt
|
||||||
public bool PasskeyPromptDismissed { get; set; } = false;
|
public bool PasskeyPromptDismissed { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
|
||||||
|
public string? KioskPin { get; set; }
|
||||||
|
|
||||||
// Ban
|
// Ban
|
||||||
public bool IsBanned { get; set; } = false;
|
public bool IsBanned { get; set; } = false;
|
||||||
public DateTime? BannedAt { get; set; }
|
public DateTime? BannedAt { get; set; }
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ public class Company : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? KioskActivationToken { get; set; }
|
public string? KioskActivationToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
|
||||||
|
public bool TimeclockEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
|
|
||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||||
|
|||||||
@@ -35,4 +35,19 @@ public class CustomItemTemplate : BaseEntity
|
|||||||
/// Path format: {companyId}/{templateId}/diagram.{ext}
|
/// Path format: {companyId}/{templateId}/diagram.{ext}
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? DiagramImagePath { get; set; }
|
public string? DiagramImagePath { get; set; }
|
||||||
|
|
||||||
|
// ── Community library tracking ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Set when this template was imported from the community library.
|
||||||
|
/// Null for originally created templates.
|
||||||
|
/// </summary>
|
||||||
|
public int? SourceFormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True once the user edits an imported template. Only modified imports (and original
|
||||||
|
/// creations) are eligible to be shared back to the community library.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsModifiedFromSource { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,17 @@ public class Customer : BaseEntity
|
|||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public DateTime? LastContactDate { get; set; }
|
public DateTime? LastContactDate { get; set; }
|
||||||
|
|
||||||
|
// CRM fields
|
||||||
|
/// <summary>How the customer found the shop (Walk-In, Google Search, Customer Referral, etc.).</summary>
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
// Ship-to / alternate address (separate from billing address above)
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
|
|
||||||
// Notification preferences
|
// Notification preferences
|
||||||
public bool NotifyByEmail { get; set; } = true;
|
public bool NotifyByEmail { get; set; } = true;
|
||||||
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
||||||
@@ -55,4 +66,5 @@ public class Customer : BaseEntity
|
|||||||
|
|
||||||
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
||||||
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
||||||
|
public virtual ICollection<CustomerContact> CustomerContacts { get; set; } = new List<CustomerContact>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An additional contact person associated with a customer account.
|
||||||
|
/// Commercial customers frequently have separate billing, operations, and drop-off contacts.
|
||||||
|
/// The primary contact remains on the Customer entity; these are supplementary.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerContact : BaseEntity
|
||||||
|
{
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Job title / role at the company, e.g. "Purchasing Manager".</summary>
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Functional role: Billing, Operations, Drop-off, Sales, General, etc.</summary>
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? ContactRole { get; set; }
|
||||||
|
|
||||||
|
[StringLength(200)]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[StringLength(20)]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[StringLength(20)]
|
||||||
|
public string? MobilePhone { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public virtual Customer? Customer { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Facility-level clock-in/clock-out record for an employee.
|
||||||
|
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
|
||||||
|
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
|
||||||
|
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
|
||||||
|
/// </summary>
|
||||||
|
public class EmployeeClockEntry : BaseEntity
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null means the employee is currently clocked in.</summary>
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
|
||||||
|
public decimal? HoursWorked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this segment is regular work time, a break, or a lunch period.
|
||||||
|
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
|
||||||
|
/// </summary>
|
||||||
|
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public virtual ApplicationUser User { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
|
|||||||
public string? Location { get; set; }
|
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; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a company imported a specific FormulaLibraryItem into their local template library.
|
||||||
|
/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
|
||||||
|
/// same item overwrites the existing row rather than creating a duplicate.
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryImport : BaseEntity
|
||||||
|
{
|
||||||
|
public int FormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||||
|
|
||||||
|
public string ImportedByUserId { get; set; } = string.Empty;
|
||||||
|
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>The CustomItemTemplate row created in this company's local library on import.</summary>
|
||||||
|
public int ResultingCustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-level community library entry for a shared custom formula template.
|
||||||
|
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
|
||||||
|
/// Shared voluntarily by the originating company; imported as independent copies by others.
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// ── Formula content (copied from CustomItemTemplate at share time) ─────
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.</summary>
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
|
||||||
|
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blob path referencing the source template's diagram image.
|
||||||
|
/// Nulled out (here and on all imports) if the source template's diagram is removed.
|
||||||
|
/// </summary>
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
|
||||||
|
// ── Attribution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Comma-separated community tags, e.g. "HVAC,Sheet Metal".</summary>
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".</summary>
|
||||||
|
public string? IndustryHint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Id of the CustomItemTemplate this was shared from.</summary>
|
||||||
|
public int SourceCustomItemTemplateId { get; set; }
|
||||||
|
|
||||||
|
public int SourceCompanyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Denormalized company name so it renders without a join when the company is gone.</summary>
|
||||||
|
public string SourceCompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When non-null, this entry was derived from an imported formula that was subsequently
|
||||||
|
/// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
|
||||||
|
/// </summary>
|
||||||
|
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem? InspiredBy { get; set; }
|
||||||
|
|
||||||
|
public string SharedByUserId { get; set; } = string.Empty;
|
||||||
|
public DateTime SharedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>False when the creator has removed it from the community library.</summary>
|
||||||
|
public bool IsPublished { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Running count of how many companies have imported this entry.</summary>
|
||||||
|
public int ImportCount { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One thumbs-up or thumbs-down vote per company per library formula.
|
||||||
|
/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
|
||||||
|
/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryRating
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int FormulaLibraryItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The company casting the vote.</summary>
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True = thumbs up, false = thumbs down.</summary>
|
||||||
|
public bool IsPositive { get; set; }
|
||||||
|
|
||||||
|
public DateTime RatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
|||||||
|
|
||||||
// Additional Information
|
// Additional Information
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Conversion tracking
|
// Conversion tracking
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
|
|||||||
|
|
||||||
// Powder selection (same pattern as current QuoteItem)
|
// Powder selection (same pattern as current QuoteItem)
|
||||||
public int? InventoryItemId { get; set; } // In-stock powder
|
public int? InventoryItemId { get; set; } // In-stock powder
|
||||||
|
/// <summary>
|
||||||
|
/// Platform powder catalog item that this coat was sourced from.
|
||||||
|
/// Persisted so that at quote-approval time the system can create exactly one
|
||||||
|
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
|
||||||
|
/// than creating during quote-save when the job may never be approved.
|
||||||
|
/// </summary>
|
||||||
|
public int? PowderCatalogItemId { get; set; }
|
||||||
public string? ColorName { get; set; } // Color name
|
public string? ColorName { get; set; } // Color name
|
||||||
public int? VendorId { get; set; } // Vendor for custom powder
|
public int? VendorId { get; set; } // Vendor for custom powder
|
||||||
public string? ColorCode { get; set; } // RAL code, etc.
|
public string? ColorCode { get; set; } // RAL code, etc.
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
|
|||||||
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
||||||
public bool AllowSms { get; set; } = false;
|
public bool AllowSms { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
|
||||||
|
public bool AllowCustomFormulas { get; set; } = false;
|
||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
|
|||||||
public virtual Customer Customer { get; set; } = null!;
|
public virtual Customer Customer { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records an inventory item as a preferred powder for a specific customer.
|
||||||
|
/// Shown on Customer Details for faster quoting of repeat orders.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerPreferredPowder : BaseEntity
|
||||||
|
{
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
public int InventoryItemId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public virtual Customer Customer { get; set; } = null!;
|
||||||
|
public virtual InventoryItem InventoryItem { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
public class JobStatusHistory : BaseEntity
|
public class JobStatusHistory : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an activated shop-floor kiosk tablet for the timeclock.
|
||||||
|
/// One row per device; multiple rows per company are supported so shops can have
|
||||||
|
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
|
||||||
|
/// device-specific cookie and validated on every kiosk request.
|
||||||
|
/// </summary>
|
||||||
|
public class TimeclockKioskDevice : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
|
||||||
|
public string? DeviceName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when a manager activated this device.</summary>
|
||||||
|
public DateTime ActivatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
|
||||||
|
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
|
||||||
|
/// </summary>
|
||||||
|
public enum ClockEntryType
|
||||||
|
{
|
||||||
|
/// <summary>Normal productive work time (default).</summary>
|
||||||
|
Work = 0,
|
||||||
|
|
||||||
|
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
|
||||||
|
Break = 1,
|
||||||
|
|
||||||
|
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
|
||||||
|
Lunch = 2
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IJobPhotoRepository JobPhotos { get; }
|
IJobPhotoRepository JobPhotos { get; }
|
||||||
IRepository<JobNote> JobNotes { get; }
|
IRepository<JobNote> JobNotes { get; }
|
||||||
IRepository<CustomerNote> CustomerNotes { get; }
|
IRepository<CustomerNote> CustomerNotes { get; }
|
||||||
|
IRepository<CustomerContact> CustomerContacts { get; }
|
||||||
|
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||||
IRepository<PricingTier> PricingTiers { get; }
|
IRepository<PricingTier> PricingTiers { get; }
|
||||||
|
|
||||||
@@ -135,7 +137,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
|||||||
IPlainRepository<Announcement> Announcements { get; }
|
IPlainRepository<Announcement> Announcements { get; }
|
||||||
IPlainRepository<BannedIp> BannedIps { get; }
|
IPlainRepository<BannedIp> BannedIps { get; }
|
||||||
IPlainRepository<DashboardTip> DashboardTips { get; }
|
IPlainRepository<DashboardTip> DashboardTips { get; }
|
||||||
IRepository<InAppNotification> InAppNotifications { get; }
|
IInAppNotificationRepository InAppNotifications { get; }
|
||||||
IPlainRepository<ReleaseNote> ReleaseNotes { get; }
|
IPlainRepository<ReleaseNote> ReleaseNotes { get; }
|
||||||
|
|
||||||
// Bug Reports
|
// Bug Reports
|
||||||
@@ -158,6 +160,15 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
|||||||
// Custom Formula Templates
|
// Custom Formula Templates
|
||||||
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
|
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
|
||||||
|
|
||||||
|
// Formula Community Library
|
||||||
|
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
|
||||||
|
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
|
||||||
|
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
|
||||||
|
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync();
|
Task<int> SaveChangesAsync();
|
||||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||||
|
|
||||||
|
|||||||
@@ -13,37 +13,39 @@ public interface IBillRepository : IRepository<Bill>
|
|||||||
/// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
|
/// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
|
||||||
/// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
|
/// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Bill?> LoadForViewAsync(int id);
|
Task<Bill?> LoadForViewAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads a single bill with only its line items for the Edit form. Excludes payment
|
/// Loads a single bill with only its line items for the Edit form. Excludes payment
|
||||||
/// navigations since those are read-only after the bill is opened.
|
/// navigations since those are read-only after the bill is opened.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Bill?> LoadForEditAsync(int id);
|
Task<Bill?> LoadForEditAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
|
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
|
||||||
/// Includes Vendor so the list row can display vendor name without a second round trip.
|
/// Includes Vendor so the list row can display vendor name without a second round trip.
|
||||||
/// LineItems are included for the search-in-description condition only.
|
/// LineItems are included for the search-in-description condition only.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount);
|
Task<List<Bill>> GetForIndexAsync(int companyId, string? statusFilter, string? searchTerm, decimal? searchAmount);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last bill number with the given prefix (including soft-deleted records) for
|
/// Returns the last bill number with the given prefix (including soft-deleted records) for
|
||||||
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
|
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
|
||||||
|
/// Scoped to <paramref name="companyId"/> so sequences are per-tenant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetLastBillNumberAsync(string prefix);
|
Task<string?> GetLastBillNumberAsync(int companyId, string prefix);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last payment number with the given prefix (including soft-deleted records)
|
/// Returns the last payment number with the given prefix (including soft-deleted records)
|
||||||
/// for sequential payment reference generation.
|
/// for sequential payment reference generation.
|
||||||
|
/// Scoped to <paramref name="companyId"/> so sequences are per-tenant.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<string?> GetLastPaymentNumberAsync(string prefix);
|
Task<string?> GetLastPaymentNumberAsync(int companyId, string prefix);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
|
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
|
||||||
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
|
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
|
||||||
/// Used by the accounting data export to produce QuickBooks IIF / CSV files.
|
/// Scoped to <paramref name="companyId"/>. Used by the accounting data export.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end);
|
Task<List<Bill>> GetForDateRangeAsync(int companyId, DateTime start, DateTime end);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public interface ICustomerRepository : IRepository<Customer>
|
|||||||
/// Loads a single customer with the navigations needed by the Details view: PricingTier,
|
/// Loads a single customer with the navigations needed by the Details view: PricingTier,
|
||||||
/// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
|
/// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Customer?> LoadForDetailsAsync(int id);
|
Task<Customer?> LoadForDetailsAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds a customer by email address within the current tenant. Used for duplicate-email
|
/// Finds a customer by email address within the current tenant. Used for duplicate-email
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Interfaces.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed repository for <see cref="InAppNotification"/> providing DB-level pagination and
|
||||||
|
/// bounded reads for the bell-dropdown and notification history page. The generic
|
||||||
|
/// <see cref="IRepository{T}"/> returns materialized lists so ordering/limiting must happen
|
||||||
|
/// in C#; these methods push ORDER BY, SKIP, and TAKE into SQL where they belong.
|
||||||
|
/// </summary>
|
||||||
|
public interface IInAppNotificationRepository : IRepository<InAppNotification>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a page of notifications ordered newest-first, plus the total un-paged count.
|
||||||
|
/// SuperAdmin path filters to CompanyId == 0 (platform notifications only).
|
||||||
|
/// </summary>
|
||||||
|
Task<(List<InAppNotification> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
bool isPlatformAdmin, int pageNumber, int pageSize);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <paramref name="take"/> most recent notifications (read and unread)
|
||||||
|
/// for the bell dropdown. SuperAdmin path is scoped to platform notifications.
|
||||||
|
/// </summary>
|
||||||
|
Task<List<InAppNotification>> GetRecentAsync(bool isPlatformAdmin, int take = 20);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <paramref name="take"/> most recent unread notifications plus the full
|
||||||
|
/// unread count for the bell badge. SuperAdmin path is scoped to platform notifications.
|
||||||
|
/// </summary>
|
||||||
|
Task<(List<InAppNotification> Items, int UnreadCount)> GetUnreadAsync(
|
||||||
|
bool isPlatformAdmin, int take = 20);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ public interface IInvoiceRepository : IRepository<Invoice>
|
|||||||
/// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
|
/// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
|
||||||
/// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
|
/// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Invoice?> LoadForViewAsync(int id);
|
Task<Invoice?> LoadForViewAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the invoice linked to a job, or null if none exists. Pass
|
/// Returns the invoice linked to a job, or null if none exists. Pass
|
||||||
|
|||||||
@@ -22,26 +22,26 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
|
/// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
|
||||||
/// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
|
/// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForDetailsAsync(int id);
|
Task<Job?> LoadForDetailsAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads a single job with the include chain required by the Edit form: same as
|
/// Loads a single job with the include chain required by the Edit form: same as
|
||||||
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
|
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
|
||||||
/// with tracking enabled so changes can be saved.
|
/// with tracking enabled so changes can be saved.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForEditAsync(int id);
|
Task<Job?> LoadForEditAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
|
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
|
||||||
/// Includes only JobStatus. Returns null if not found or soft-deleted.
|
/// Includes only JobStatus. Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForStatusChangeAsync(int id);
|
Task<Job?> LoadForStatusChangeAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
|
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
|
||||||
/// loaded. Used by the Details view changelog tab.
|
/// loaded. Used by the Details view changelog tab.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId);
|
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
|
/// Returns the last job number that starts with <paramref name="prefix"/> for the given
|
||||||
@@ -84,7 +84,7 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
|
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
|
||||||
/// Returns null if not found or soft-deleted.
|
/// Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
|
Task<Job?> LoadForTemplateSnapshotAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
|
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
|
||||||
@@ -96,6 +96,7 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||||
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||||
|
/// Scoped to <paramref name="companyId"/> to prevent cross-tenant count collisions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<int> GetReworkJobCountAsync(int originalJobId);
|
Task<int> GetReworkJobCountAsync(int originalJobId, int companyId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ namespace PowderCoating.Core.Interfaces.Repositories;
|
|||||||
public interface INotificationLogRepository : IRepository<NotificationLog>
|
public interface INotificationLogRepository : IRepository<NotificationLog>
|
||||||
{
|
{
|
||||||
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
|
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
|
||||||
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId);
|
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
|
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
|
||||||
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId);
|
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
|
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
|
||||||
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId);
|
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
|
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
|
||||||
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId);
|
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
|
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
|
||||||
Task<NotificationLog?> GetLatestForJobAsync(int jobId);
|
Task<NotificationLog?> GetLatestForJobAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
|
/// <summary>Returns all notification log entries for the given job, newest-first.</summary>
|
||||||
Task<List<NotificationLog>> GetAllForJobAsync(int jobId);
|
Task<List<NotificationLog>> GetAllForJobAsync(int jobId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
|
/// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public interface IQuoteRepository : IRepository<Quote>
|
|||||||
/// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
|
/// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
|
||||||
/// Returns null if not found or soft-deleted.
|
/// Returns null if not found or soft-deleted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<Quote?> LoadForDetailsAsync(int id);
|
Task<Quote?> LoadForDetailsAsync(int id, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads a single quote by its customer-facing approval token. Ignores global query filters
|
/// Loads a single quote by its customer-facing approval token. Ignores global query filters
|
||||||
@@ -29,7 +29,7 @@ public interface IQuoteRepository : IRepository<Quote>
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
|
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId);
|
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
|
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
|
||||||
@@ -43,7 +43,7 @@ public interface IQuoteRepository : IRepository<Quote>
|
|||||||
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
|
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
|
||||||
/// because it skips the parent-quote navigations that callers already have.
|
/// because it skips the parent-quote navigations that callers already have.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId);
|
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId, int companyId);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
|
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given
|
||||||
|
|||||||
@@ -230,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
public DbSet<JobNote> JobNotes { get; set; }
|
public DbSet<JobNote> JobNotes { get; set; }
|
||||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||||||
|
/// <summary>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<CustomerContact> CustomerContacts { get; set; }
|
||||||
|
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
|
||||||
|
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
|
||||||
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||||||
@@ -289,6 +293,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
|
||||||
|
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
|
||||||
|
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
|
||||||
|
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
|
||||||
|
|
||||||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<BugReport> BugReports { get; set; }
|
public DbSet<BugReport> BugReports { get; set; }
|
||||||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||||||
@@ -378,6 +391,13 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
|
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
|
||||||
|
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
|
||||||
|
|
||||||
|
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
|
||||||
|
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
||||||
@@ -535,6 +555,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||||
@@ -771,6 +793,32 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(k => k.LinkedJobId)
|
.HasForeignKey(k => k.LinkedJobId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Custom Formula Templates — tenant-filtered + soft delete
|
||||||
|
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
|
||||||
|
// Employee Timeclock — tenant-filtered + soft delete
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
|
||||||
|
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>()
|
||||||
|
.HasOne(c => c.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
// Composite index for "who's clocked in today" and date-range attendance reports
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>()
|
||||||
|
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
|
||||||
|
|
||||||
|
// Timeclock kiosk devices — one row per activated tablet per company
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
|
||||||
|
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||||
|
.HasIndex(d => d.Token).IsUnique();
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||||
|
.HasIndex(d => d.CompanyId);
|
||||||
|
|
||||||
// Account self-referencing hierarchy
|
// Account self-referencing hierarchy
|
||||||
modelBuilder.Entity<Account>()
|
modelBuilder.Entity<Account>()
|
||||||
.HasOne(a => a.ParentAccount)
|
.HasOne(a => a.ParentAccount)
|
||||||
@@ -983,6 +1031,17 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
modelBuilder.Entity<AuditLog>()
|
modelBuilder.Entity<AuditLog>()
|
||||||
.HasIndex(a => new { a.EntityType, a.EntityId });
|
.HasIndex(a => new { a.EntityType, a.EntityId });
|
||||||
|
|
||||||
|
// InAppNotification — bell endpoint fires on every page load; compound indexes let SQL
|
||||||
|
// evaluate the tenant + soft-delete filter then sort/count without a full-table scan.
|
||||||
|
modelBuilder.Entity<InAppNotification>()
|
||||||
|
.HasIndex(n => new { n.CompanyId, n.IsDeleted, n.CreatedAt });
|
||||||
|
modelBuilder.Entity<InAppNotification>()
|
||||||
|
.HasIndex(n => new { n.CompanyId, n.IsDeleted, n.IsRead });
|
||||||
|
|
||||||
|
// ContactSubmission — SuperAdmin inbox; filter + sort covered by a single index.
|
||||||
|
modelBuilder.Entity<ContactSubmission>()
|
||||||
|
.HasIndex(c => new { c.CompanyId, c.IsDeleted, c.CreatedAt });
|
||||||
|
|
||||||
// Announcements — no tenant filter; visible based on Target logic in app layer
|
// Announcements — no tenant filter; visible based on Target logic in app layer
|
||||||
modelBuilder.Entity<AnnouncementDismissal>()
|
modelBuilder.Entity<AnnouncementDismissal>()
|
||||||
.HasOne(d => d.Announcement)
|
.HasOne(d => d.Announcement)
|
||||||
@@ -1677,6 +1736,23 @@ modelBuilder.Entity<Job>()
|
|||||||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||||||
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||||
|
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||||
|
.HasOne(p => p.Customer)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.CustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||||
|
.HasOne(p => p.InventoryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.InventoryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// END PERFORMANCE OPTIMIZATION INDEXES
|
// END PERFORMANCE OPTIMIZATION INDEXES
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -2041,6 +2117,61 @@ modelBuilder.Entity<Job>()
|
|||||||
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
||||||
|
|
||||||
|
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
|
||||||
|
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasOne(f => f.InspiredBy)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasIndex(f => f.SourceCompanyId)
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasIndex(f => f.IsPublished)
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||||
|
|
||||||
|
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasOne(i => i.FormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FormulaLibraryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasOne(i => i.ResultingCustomItemTemplate)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||||
|
|
||||||
|
// FormulaLibraryRating — platform-level; one vote per company per formula
|
||||||
|
modelBuilder.Entity<FormulaLibraryRating>()
|
||||||
|
.HasOne(r => r.FormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FormulaLibraryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryRating>()
|
||||||
|
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||||
|
|
||||||
|
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
|
||||||
|
modelBuilder.Entity<CustomItemTemplate>()
|
||||||
|
.HasOne(t => t.SourceFormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2145,7 +2276,9 @@ modelBuilder.Entity<Job>()
|
|||||||
|
|
||||||
if (entry.State == EntityState.Added)
|
if (entry.State == EntityState.Added)
|
||||||
{
|
{
|
||||||
entity.CreatedAt = DateTime.UtcNow;
|
// Only stamp if not already set — seeders set historical dates and rely on them being preserved.
|
||||||
|
if (entity.CreatedAt == default)
|
||||||
|
entity.CreatedAt = DateTime.UtcNow;
|
||||||
entity.CreatedBy = currentUser;
|
entity.CreatedBy = currentUser;
|
||||||
|
|
||||||
// Auto-set CompanyId for new entities (if not already set)
|
// Auto-set CompanyId for new entities (if not already set)
|
||||||
|
|||||||
+10672
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakeMaintenanceIntervalNullable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
type: "int",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAllowCustomFormulas : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowCustomFormulas",
|
||||||
|
table: "SubscriptionPlanConfigs",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowCustomFormulas",
|
||||||
|
table: "SubscriptionPlanConfigs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10854
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEmployeeTimeclock : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskPin",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EmployeeClockEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
ClockInTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
ClockOutTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
HoursWorked = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EmployeeClockEntries", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EmployeeClockEntries_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmployeeClockEntries_CompanyId_ClockInTime",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
columns: new[] { "CompanyId", "ClockInTime" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmployeeClockEntries_UserId",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EmployeeClockEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskPin",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10857
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTimeclockKioskToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTimeclockSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||||
|
table: "Companies",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "TimeclockAutoClockOutHours",
|
||||||
|
table: "Companies",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TimeclockEnabled",
|
||||||
|
table: "Companies",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TimeclockKioskDevices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Token = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TimeclockKioskDevices", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TimeclockKioskDevices_CompanyId",
|
||||||
|
table: "TimeclockKioskDevices",
|
||||||
|
column: "CompanyId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TimeclockKioskDevices_Token",
|
||||||
|
table: "TimeclockKioskDevices",
|
||||||
|
column: "Token",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TimeclockKioskDevices");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockAutoClockOutHours",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockEnabled",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10921
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClockEntryType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "EntryType",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EntryType",
|
||||||
|
table: "EmployeeClockEntries");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10924
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPowderCatalogItemIdToCoat : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "QuoteItemCoats",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "QuoteItemCoats");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11119
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,214 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFormulaLibrary : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsModifiedFromSource",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FormulaLibraryItems",
|
||||||
|
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),
|
||||||
|
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Tags = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IndustryHint = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
SourceCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SourceCompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
SourceCompanyName = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
InspiredByFormulaLibraryItemId = table.Column<int>(type: "int", nullable: true),
|
||||||
|
SharedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
SharedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
IsPublished = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
ImportCount = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FormulaLibraryItems", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryItems_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
|
||||||
|
column: x => x.InspiredByFormulaLibraryItemId,
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id");
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FormulaLibraryImports",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
ImportedByUserId = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
ImportedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
ResultingCustomItemTemplateId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FormulaLibraryImports", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryImports_CustomItemTemplates_ResultingCustomItemTemplateId",
|
||||||
|
column: x => x.ResultingCustomItemTemplateId,
|
||||||
|
principalTable: "CustomItemTemplates",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryImports_FormulaLibraryItems_FormulaLibraryItemId",
|
||||||
|
column: x => x.FormulaLibraryItemId,
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
column: "SourceFormulaLibraryItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryImports_Company_Item",
|
||||||
|
table: "FormulaLibraryImports",
|
||||||
|
columns: new[] { "CompanyId", "FormulaLibraryItemId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryImports_FormulaLibraryItemId",
|
||||||
|
table: "FormulaLibraryImports",
|
||||||
|
column: "FormulaLibraryItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryImports_ResultingCustomItemTemplateId",
|
||||||
|
table: "FormulaLibraryImports",
|
||||||
|
column: "ResultingCustomItemTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryItems_InspiredByFormulaLibraryItemId",
|
||||||
|
table: "FormulaLibraryItems",
|
||||||
|
column: "InspiredByFormulaLibraryItemId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryItems_IsPublished",
|
||||||
|
table: "FormulaLibraryItems",
|
||||||
|
column: "IsPublished");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryItems_SourceCompanyId",
|
||||||
|
table: "FormulaLibraryItems",
|
||||||
|
column: "SourceCompanyId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates",
|
||||||
|
column: "SourceFormulaLibraryItemId",
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CustomItemTemplates_FormulaLibraryItems_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FormulaLibraryImports");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FormulaLibraryItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CustomItemTemplates_SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsModifiedFromSource",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "SourceFormulaLibraryItemId",
|
||||||
|
table: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11159
File diff suppressed because it is too large
Load Diff
+92
@@ -0,0 +1,92 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddFormulaLibraryRatings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "FormulaLibraryRatings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
FormulaLibraryItemId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsPositive = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
RatedAt = table.Column<DateTime>(type: "datetime2", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_FormulaLibraryRatings", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_FormulaLibraryRatings_FormulaLibraryItems_FormulaLibraryItemId",
|
||||||
|
column: x => x.FormulaLibraryItemId,
|
||||||
|
principalTable: "FormulaLibraryItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_FormulaLibraryRatings_Item_Company",
|
||||||
|
table: "FormulaLibraryRatings",
|
||||||
|
columns: new[] { "FormulaLibraryItemId", "CompanyId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "FormulaLibraryRatings");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3849));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3855));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 28, 1, 1, 15, 582, DateTimeKind.Utc).AddTicks(3856));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
src/PowderCoating.Infrastructure/Migrations/20260608182208_AddProjectNameToQuotesAndJobs.Designer.cs
Generated
+11165
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddProjectNameToQuotesAndJobs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Quotes",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Jobs",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Quotes");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Jobs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11168
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 AddInvoiceProjectName : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Invoices",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ProjectName",
|
||||||
|
table: "Invoices");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7640));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7646));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 8, 18, 22, 3, 652, DateTimeKind.Utc).AddTicks(7647));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11239
File diff suppressed because it is too large
Load Diff
+110
@@ -0,0 +1,110 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerPreferredPowders : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerPreferredPowders",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
CustomerId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
InventoryItemId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomerPreferredPowders", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerPreferredPowders_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerPreferredPowders_InventoryItems_InventoryItemId",
|
||||||
|
column: x => x.InventoryItemId,
|
||||||
|
principalTable: "InventoryItems",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerPreferredPowders_CustomerId_InventoryItemId",
|
||||||
|
table: "CustomerPreferredPowders",
|
||||||
|
columns: new[] { "CustomerId", "InventoryItemId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerPreferredPowders_InventoryItemId",
|
||||||
|
table: "CustomerPreferredPowders",
|
||||||
|
column: "InventoryItemId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerPreferredPowders");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2471));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2477));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 12, 48, 23, 21, DateTimeKind.Utc).AddTicks(2478));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11345
File diff suppressed because it is too large
Load Diff
+164
@@ -0,0 +1,164 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomerContactsAndCrmFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "LeadSource",
|
||||||
|
table: "Customers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShipToAddress",
|
||||||
|
table: "Customers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShipToCity",
|
||||||
|
table: "Customers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShipToCountry",
|
||||||
|
table: "Customers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShipToState",
|
||||||
|
table: "Customers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ShipToZipCode",
|
||||||
|
table: "Customers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomerContacts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
CustomerId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: false),
|
||||||
|
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||||
|
Title = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
|
||||||
|
ContactRole = table.Column<string>(type: "nvarchar(50)", maxLength: 50, nullable: true),
|
||||||
|
Email = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
|
||||||
|
Phone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||||
|
MobilePhone = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(500)", maxLength: 500, 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_CustomerContacts", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_CustomerContacts_Customers_CustomerId",
|
||||||
|
column: x => x.CustomerId,
|
||||||
|
principalTable: "Customers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CustomerContacts_CustomerId",
|
||||||
|
table: "CustomerContacts",
|
||||||
|
column: "CustomerId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomerContacts");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "LeadSource",
|
||||||
|
table: "Customers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShipToAddress",
|
||||||
|
table: "Customers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShipToCity",
|
||||||
|
table: "Customers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShipToCountry",
|
||||||
|
table: "Customers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShipToState",
|
||||||
|
table: "Customers");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ShipToZipCode",
|
||||||
|
table: "Customers");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9947));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9953));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 9, 23, 42, 41, 419, DateTimeKind.Utc).AddTicks(9954));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11351
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddNotificationAndContactIndexes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InAppNotifications_CompanyId_IsDeleted_CreatedAt",
|
||||||
|
table: "InAppNotifications",
|
||||||
|
columns: new[] { "CompanyId", "IsDeleted", "CreatedAt" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InAppNotifications_CompanyId_IsDeleted_IsRead",
|
||||||
|
table: "InAppNotifications",
|
||||||
|
columns: new[] { "CompanyId", "IsDeleted", "IsRead" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ContactSubmissions_CompanyId_IsDeleted_CreatedAt",
|
||||||
|
table: "ContactSubmissions",
|
||||||
|
columns: new[] { "CompanyId", "IsDeleted", "CreatedAt" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_InAppNotifications_CompanyId_IsDeleted_CreatedAt",
|
||||||
|
table: "InAppNotifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_InAppNotifications_CompanyId_IsDeleted_IsRead",
|
||||||
|
table: "InAppNotifications");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_ContactSubmissions_CompanyId_IsDeleted_CreatedAt",
|
||||||
|
table: "ContactSubmissions");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsBanned")
|
b.Property<bool>("IsBanned")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("KioskPin")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<decimal?>("LaborCostPerHour")
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -1923,6 +1926,15 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("TimeZone")
|
b.Property<string>("TimeZone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("TimeclockAllowMultiplePunchesPerDay")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int?>("TimeclockAutoClockOutHours")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("TimeclockEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -2502,6 +2514,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
|
||||||
|
|
||||||
b.ToTable("ContactSubmissions");
|
b.ToTable("ContactSubmissions");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2699,6 +2713,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsModifiedFromSource")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2713,6 +2730,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("RateLabel")
|
b.Property<string>("RateLabel")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("SourceFormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -2721,6 +2741,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SourceFormulaLibraryItemId");
|
||||||
|
|
||||||
b.ToTable("CustomItemTemplates");
|
b.ToTable("CustomItemTemplates");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2798,6 +2820,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime?>("LastContactDate")
|
b.Property<DateTime?>("LastContactDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("LeadSource")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("MobilePhone")
|
b.Property<string>("MobilePhone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -2816,6 +2841,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("PricingTierId")
|
b.Property<int?>("PricingTierId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToAddress")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToCity")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToCountry")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToState")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToZipCode")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("SmsConsentMethod")
|
b.Property<string>("SmsConsentMethod")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -2874,6 +2914,81 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("Customers");
|
b.ToTable("Customers");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ContactRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("MobilePhone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.ToTable("CustomerContacts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2924,6 +3039,58 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("CustomerNotes");
|
b.ToTable("CustomerNotes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", 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<int>("CustomerId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("InventoryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InventoryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "InventoryItemId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||||
|
|
||||||
|
b.ToTable("CustomerPreferredPowders");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3034,6 +3201,66 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("Deposits");
|
b.ToTable("Deposits");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ClockInTime")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClockOutTime")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
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<int>("EntryType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("HoursWorked")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "ClockInTime");
|
||||||
|
|
||||||
|
b.ToTable("EmployeeClockEntries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3119,7 +3346,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")
|
||||||
@@ -3365,6 +3592,183 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("FixedAssetDepreciationEntries");
|
b.ToTable("FixedAssetDepreciationEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", 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<int>("FormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ImportedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("ImportedByUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("ResultingCustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FormulaLibraryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("ResultingCustomItemTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "FormulaLibraryItemId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryImports");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal?>("DefaultRate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DiagramImagePath")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("FieldsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Formula")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("ImportCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("IndustryHint")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("InspiredByFormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.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>("SharedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SharedByUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SourceCompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SourceCompanyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SourceCustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Tags")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InspiredByFormulaLibraryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("IsPublished")
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||||
|
|
||||||
|
b.HasIndex("SourceCompanyId")
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("FormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPositive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FormulaLibraryItemId", "CompanyId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryRatings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3588,6 +3992,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("QuoteId");
|
b.HasIndex("QuoteId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "IsDeleted", "IsRead");
|
||||||
|
|
||||||
b.ToTable("InAppNotifications");
|
b.ToTable("InAppNotifications");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4012,6 +4420,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("PublicViewToken")
|
b.Property<string>("PublicViewToken")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -4303,6 +4714,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PricingBreakdownJson")
|
b.Property<string>("PricingBreakdownJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -6796,7 +7210,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869),
|
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6807,7 +7221,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876),
|
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6818,7 +7232,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878),
|
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7128,6 +7542,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("ProfitPercent")
|
b.Property<decimal>("ProfitPercent")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ProspectAddress")
|
b.Property<string>("ProspectAddress")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -7510,6 +7927,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("PowderCatalogItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("PowderCostPerLb")
|
b.Property<decimal?>("PowderCostPerLb")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -8172,6 +8592,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("AllowAiPhotoQuotes")
|
b.Property<bool>("AllowAiPhotoQuotes")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowCustomFormulas")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("AllowOnlinePayments")
|
b.Property<bool>("AllowOnlinePayments")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -8348,6 +8771,61 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("TermsAcceptances");
|
b.ToTable("TermsAcceptances");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.TimeclockKioskDevice", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ActivatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
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>("DeviceName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeenAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TimeclockKioskDevices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -9130,6 +9608,16 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Invoice");
|
b.Navigation("Invoice");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "SourceFormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SourceFormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("SourceFormulaLibraryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -9146,6 +9634,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("PricingTier");
|
b.Navigation("PricingTier");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||||
|
.WithMany("CustomerContacts")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||||
@@ -9157,6 +9656,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Customer");
|
b.Navigation("Customer");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InventoryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("InventoryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
||||||
@@ -9193,6 +9711,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("RecordedBy");
|
b.Navigation("RecordedBy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -9275,6 +9804,46 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "ResultingCustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ResultingCustomItemTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("FormulaLibraryItem");
|
||||||
|
|
||||||
|
b.Navigation("ResultingCustomItemTemplate");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "InspiredBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InspiredByFormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.Navigation("InspiredBy");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("FormulaLibraryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||||
@@ -10601,6 +11170,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("CustomerContacts");
|
||||||
|
|
||||||
b.Navigation("CustomerNotes");
|
b.Navigation("CustomerNotes");
|
||||||
|
|
||||||
b.Navigation("Invoices");
|
b.Navigation("Invoices");
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
|||||||
public BillRepository(ApplicationDbContext context) : base(context) { }
|
public BillRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Bill?> LoadForViewAsync(int id)
|
public async Task<Bill?> LoadForViewAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.Where(b => b.Id == id && !b.IsDeleted)
|
.Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
|
||||||
.Include(b => b.Vendor)
|
.Include(b => b.Vendor)
|
||||||
.Include(b => b.APAccount)
|
.Include(b => b.APAccount)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
@@ -31,21 +31,21 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Bill?> LoadForEditAsync(int id)
|
public async Task<Bill?> LoadForEditAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.Where(b => b.Id == id && !b.IsDeleted)
|
.Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount)
|
public async Task<List<Bill>> GetForIndexAsync(int companyId, string? statusFilter, string? searchTerm, decimal? searchAmount)
|
||||||
{
|
{
|
||||||
var query = _context.Bills
|
var query = _context.Bills
|
||||||
.Include(b => b.Vendor)
|
.Include(b => b.Vendor)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
.Where(b => !b.IsDeleted);
|
.Where(b => b.CompanyId == companyId && !b.IsDeleted);
|
||||||
|
|
||||||
if (statusFilter == "Unpaid")
|
if (statusFilter == "Unpaid")
|
||||||
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
|
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
|
||||||
@@ -69,32 +69,32 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<string?> GetLastBillNumberAsync(string prefix)
|
public async Task<string?> GetLastBillNumberAsync(int companyId, string prefix)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
.Where(b => b.CompanyId == companyId && b.BillNumber.StartsWith(prefix))
|
||||||
.OrderByDescending(b => b.BillNumber)
|
.OrderByDescending(b => b.BillNumber)
|
||||||
.Select(b => b.BillNumber)
|
.Select(b => b.BillNumber)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<string?> GetLastPaymentNumberAsync(string prefix)
|
public async Task<string?> GetLastPaymentNumberAsync(int companyId, string prefix)
|
||||||
{
|
{
|
||||||
return await _context.BillPayments
|
return await _context.BillPayments
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(p => p.PaymentNumber.StartsWith(prefix))
|
.Where(p => p.CompanyId == companyId && p.PaymentNumber.StartsWith(prefix))
|
||||||
.OrderByDescending(p => p.PaymentNumber)
|
.OrderByDescending(p => p.PaymentNumber)
|
||||||
.Select(p => p.PaymentNumber)
|
.Select(p => p.PaymentNumber)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end)
|
public async Task<List<Bill>> GetForDateRangeAsync(int companyId, DateTime start, DateTime end)
|
||||||
{
|
{
|
||||||
return await _context.Bills
|
return await _context.Bills
|
||||||
.Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
|
.Where(b => b.CompanyId == companyId && !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
|
||||||
.Include(b => b.Vendor)
|
.Include(b => b.Vendor)
|
||||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||||
.ThenInclude(li => li.Account)
|
.ThenInclude(li => li.Account)
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ public class CustomerRepository : Repository<Customer>, ICustomerRepository
|
|||||||
public CustomerRepository(ApplicationDbContext context) : base(context) { }
|
public CustomerRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Customer?> LoadForDetailsAsync(int id)
|
public async Task<Customer?> LoadForDetailsAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Customers
|
return await _context.Customers
|
||||||
.Where(c => c.Id == id && !c.IsDeleted)
|
.Where(c => c.Id == id && c.CompanyId == companyId && !c.IsDeleted)
|
||||||
.Include(c => c.PricingTier)
|
.Include(c => c.PricingTier)
|
||||||
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
|
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Interfaces.Repositories;
|
||||||
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed repository for <see cref="InAppNotification"/>. Overrides the three hot-path
|
||||||
|
/// read operations so ORDER BY / SKIP / TAKE run in SQL rather than in C# after a full
|
||||||
|
/// table load — critical for the bell dropdown (Recent/Unread) which fires on every page.
|
||||||
|
/// </summary>
|
||||||
|
public class InAppNotificationRepository
|
||||||
|
: Repository<InAppNotification>, IInAppNotificationRepository
|
||||||
|
{
|
||||||
|
public InAppNotificationRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(List<InAppNotification> Items, int TotalCount)> GetPagedAsync(
|
||||||
|
bool isPlatformAdmin, int pageNumber, int pageSize)
|
||||||
|
{
|
||||||
|
// SuperAdmin path: bypass global filters, scope to CompanyId 0 (platform-only).
|
||||||
|
// Regular path: global filter already scopes to the current tenant.
|
||||||
|
var query = isPlatformAdmin
|
||||||
|
? _context.Set<InAppNotification>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(n => !n.IsDeleted && n.CompanyId == 0)
|
||||||
|
: _context.Set<InAppNotification>()
|
||||||
|
.Where(n => true);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync();
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Skip((pageNumber - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return (items, totalCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<List<InAppNotification>> GetRecentAsync(
|
||||||
|
bool isPlatformAdmin, int take = 20)
|
||||||
|
{
|
||||||
|
var query = isPlatformAdmin
|
||||||
|
? _context.Set<InAppNotification>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(n => !n.IsDeleted && n.CompanyId == 0)
|
||||||
|
: _context.Set<InAppNotification>()
|
||||||
|
.Where(n => true);
|
||||||
|
|
||||||
|
return await query
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<(List<InAppNotification> Items, int UnreadCount)> GetUnreadAsync(
|
||||||
|
bool isPlatformAdmin, int take = 20)
|
||||||
|
{
|
||||||
|
// Count ALL unread for the badge, then cap the items list for the dropdown.
|
||||||
|
var baseQuery = isPlatformAdmin
|
||||||
|
? _context.Set<InAppNotification>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
|
||||||
|
: _context.Set<InAppNotification>()
|
||||||
|
.Where(n => !n.IsRead);
|
||||||
|
|
||||||
|
var unreadCount = await baseQuery.CountAsync();
|
||||||
|
|
||||||
|
var items = await baseQuery
|
||||||
|
.AsNoTracking()
|
||||||
|
.OrderByDescending(n => n.CreatedAt)
|
||||||
|
.Take(take)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return (items, unreadCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,10 +15,10 @@ public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
|
|||||||
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
|
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Invoice?> LoadForViewAsync(int id)
|
public async Task<Invoice?> LoadForViewAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Set<Invoice>()
|
return await _context.Set<Invoice>()
|
||||||
.Where(i => i.Id == id && !i.IsDeleted)
|
.Where(i => i.Id == id && i.CompanyId == companyId && !i.IsDeleted)
|
||||||
.Include(i => i.Customer)
|
.Include(i => i.Customer)
|
||||||
.Include(i => i.Job)
|
.Include(i => i.Job)
|
||||||
.Include(i => i.PreparedBy)
|
.Include(i => i.PreparedBy)
|
||||||
|
|||||||
@@ -32,13 +32,13 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForDetailsAsync(int id)
|
public async Task<Job?> LoadForDetailsAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
// Single query replaces the per-item N+1 loop that was in JobsController.Details.
|
// Single query replaces the per-item N+1 loop that was in JobsController.Details.
|
||||||
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically
|
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically
|
||||||
// (split query behavior), keeping result set size manageable.
|
// (split query behavior), keeping result set size manageable.
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Where(j => j.Id == id && !j.IsDeleted)
|
.Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
|
||||||
.Include(j => j.Customer)
|
.Include(j => j.Customer)
|
||||||
.Include(j => j.JobStatus)
|
.Include(j => j.JobStatus)
|
||||||
.Include(j => j.JobPriority)
|
.Include(j => j.JobPriority)
|
||||||
@@ -62,10 +62,10 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForEditAsync(int id)
|
public async Task<Job?> LoadForEditAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Where(j => j.Id == id && !j.IsDeleted)
|
.Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
|
||||||
.Include(j => j.Customer)
|
.Include(j => j.Customer)
|
||||||
.Include(j => j.JobStatus)
|
.Include(j => j.JobStatus)
|
||||||
.Include(j => j.JobPriority)
|
.Include(j => j.JobPriority)
|
||||||
@@ -86,18 +86,18 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForStatusChangeAsync(int id)
|
public async Task<Job?> LoadForStatusChangeAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Include(j => j.JobStatus)
|
.Include(j => j.JobStatus)
|
||||||
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
|
.FirstOrDefaultAsync(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId)
|
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.JobChangeHistories
|
return await _context.JobChangeHistories
|
||||||
.Where(h => h.JobId == jobId && !h.IsDeleted)
|
.Where(h => h.JobId == jobId && h.CompanyId == companyId && !h.IsDeleted)
|
||||||
.Include(h => h.ChangedBy)
|
.Include(h => h.ChangedBy)
|
||||||
.OrderByDescending(h => h.ChangedAt)
|
.OrderByDescending(h => h.ChangedAt)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -176,10 +176,10 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId)
|
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.Where(j => j.Id == jobId && !j.IsDeleted)
|
.Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted)
|
||||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||||
@@ -188,11 +188,11 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
public async Task<int> GetReworkJobCountAsync(int originalJobId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.Jobs
|
return await _context.Jobs
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.CountAsync(j => j.OriginalJobId == originalJobId);
|
.CountAsync(j => j.OriginalJobId == originalJobId && j.CompanyId == companyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
|
|||||||
@@ -15,50 +15,50 @@ public class NotificationLogRepository : Repository<NotificationLog>, INotificat
|
|||||||
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
|
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId) =>
|
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.InvoiceId == invoiceId)
|
.Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId) =>
|
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.InvoiceId == invoiceId)
|
.Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId) =>
|
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.QuoteId == quoteId)
|
.Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId) =>
|
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.QuoteId == quoteId)
|
.Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId) =>
|
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.JobId == jobId)
|
.Where(n => n.JobId == jobId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId) =>
|
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId, int companyId) =>
|
||||||
await _context.Set<NotificationLog>()
|
await _context.Set<NotificationLog>()
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Where(n => n.JobId == jobId)
|
.Where(n => n.JobId == jobId && n.CompanyId == companyId)
|
||||||
.OrderByDescending(n => n.SentAt)
|
.OrderByDescending(n => n.SentAt)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ public class PlainRepository<T> : IPlainRepository<T> where T : class
|
|||||||
=> await _dbSet.FindAsync(id);
|
=> await _dbSet.FindAsync(id);
|
||||||
|
|
||||||
public virtual async Task<IEnumerable<T>> GetAllAsync()
|
public virtual async Task<IEnumerable<T>> GetAllAsync()
|
||||||
=> await _dbSet.ToListAsync();
|
=> await _dbSet.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
|
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
|
||||||
=> await _dbSet.Where(predicate).ToListAsync();
|
=> await _dbSet.AsNoTracking().Where(predicate).ToListAsync();
|
||||||
|
|
||||||
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
|
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
|
||||||
=> await _dbSet.FirstOrDefaultAsync(predicate);
|
=> await _dbSet.FirstOrDefaultAsync(predicate);
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
public QuoteRepository(ApplicationDbContext context) : base(context) { }
|
public QuoteRepository(ApplicationDbContext context) : base(context) { }
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<Quote?> LoadForDetailsAsync(int id)
|
public async Task<Quote?> LoadForDetailsAsync(int id, int companyId)
|
||||||
{
|
{
|
||||||
var quote = await _context.Quotes
|
var quote = await _context.Quotes
|
||||||
.Where(q => q.Id == id && !q.IsDeleted)
|
.Where(q => q.Id == id && q.CompanyId == companyId && !q.IsDeleted)
|
||||||
.Include(q => q.Customer)
|
.Include(q => q.Customer)
|
||||||
.Include(q => q.PreparedBy)
|
.Include(q => q.PreparedBy)
|
||||||
.Include(q => q.QuoteStatus)
|
.Include(q => q.QuoteStatus)
|
||||||
@@ -32,7 +32,7 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
// QuoteItems with nested coats and prep services loaded separately to avoid
|
// QuoteItems with nested coats and prep services loaded separately to avoid
|
||||||
// cartesian explosion from multiple collection includes in a single query.
|
// cartesian explosion from multiple collection includes in a single query.
|
||||||
quote.QuoteItems = await _context.QuoteItems
|
quote.QuoteItems = await _context.QuoteItems
|
||||||
.Where(qi => qi.QuoteId == id && !qi.IsDeleted)
|
.Where(qi => qi.QuoteId == id && qi.CompanyId == companyId && !qi.IsDeleted)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
.ThenInclude(c => c.InventoryItem)
|
.ThenInclude(c => c.InventoryItem)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
@@ -58,10 +58,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId)
|
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.QuoteChangeHistories
|
return await _context.QuoteChangeHistories
|
||||||
.Where(h => h.QuoteId == quoteId && !h.IsDeleted)
|
.Where(h => h.QuoteId == quoteId && h.CompanyId == companyId && !h.IsDeleted)
|
||||||
.Include(h => h.ChangedBy)
|
.Include(h => h.ChangedBy)
|
||||||
.OrderByDescending(h => h.ChangedAt)
|
.OrderByDescending(h => h.ChangedAt)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
@@ -83,10 +83,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId)
|
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId, int companyId)
|
||||||
{
|
{
|
||||||
return await _context.QuoteItems
|
return await _context.QuoteItems
|
||||||
.Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted)
|
.Where(qi => qi.QuoteId == quoteId && qi.CompanyId == companyId && !qi.IsDeleted)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
.ThenInclude(c => c.InventoryItem)
|
.ThenInclude(c => c.InventoryItem)
|
||||||
.Include(qi => qi.Coats)
|
.Include(qi => qi.Coats)
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IJobPhotoRepository? _jobPhotos;
|
private IJobPhotoRepository? _jobPhotos;
|
||||||
private IRepository<JobNote>? _jobNotes;
|
private IRepository<JobNote>? _jobNotes;
|
||||||
private IRepository<CustomerNote>? _customerNotes;
|
private IRepository<CustomerNote>? _customerNotes;
|
||||||
|
private IRepository<CustomerContact>? _customerContacts;
|
||||||
|
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||||
private IRepository<PricingTier>? _pricingTiers;
|
private IRepository<PricingTier>? _pricingTiers;
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IPlainRepository<Announcement>? _announcements;
|
private IPlainRepository<Announcement>? _announcements;
|
||||||
private IPlainRepository<BannedIp>? _bannedIps;
|
private IPlainRepository<BannedIp>? _bannedIps;
|
||||||
private IPlainRepository<DashboardTip>? _dashboardTips;
|
private IPlainRepository<DashboardTip>? _dashboardTips;
|
||||||
private IRepository<InAppNotification>? _inAppNotifications;
|
private IInAppNotificationRepository? _inAppNotifications;
|
||||||
private IPlainRepository<ReleaseNote>? _releaseNotes;
|
private IPlainRepository<ReleaseNote>? _releaseNotes;
|
||||||
|
|
||||||
// Bug Reports
|
// Bug Reports
|
||||||
@@ -123,9 +125,18 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
// Customer Intake Kiosk
|
// Customer Intake Kiosk
|
||||||
private IRepository<KioskSession>? _kioskSessions;
|
private IRepository<KioskSession>? _kioskSessions;
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
|
||||||
|
private IRepository<TimeclockKioskDevice>? _timeclockKioskDevices;
|
||||||
|
|
||||||
// Custom Formula Templates
|
// Custom Formula Templates
|
||||||
private IRepository<CustomItemTemplate>? _customItemTemplates;
|
private IRepository<CustomItemTemplate>? _customItemTemplates;
|
||||||
|
|
||||||
|
// Formula Community Library
|
||||||
|
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
|
||||||
|
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
|
||||||
|
private IPlainRepository<FormulaLibraryRating>? _formulaLibraryRatings;
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
private IPurchaseOrderRepository? _purchaseOrders;
|
private IPurchaseOrderRepository? _purchaseOrders;
|
||||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||||
@@ -312,6 +323,11 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<CustomerNote> CustomerNotes =>
|
public IRepository<CustomerNote> CustomerNotes =>
|
||||||
_customerNotes ??= new Repository<CustomerNote>(_context);
|
_customerNotes ??= new Repository<CustomerNote>(_context);
|
||||||
|
/// <summary>Repository for <see cref="CustomerContact"/> additional contacts (billing, ops, drop-off) on commercial accounts; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<CustomerContact> CustomerContacts =>
|
||||||
|
_customerContacts ??= new Repository<CustomerContact>(_context);
|
||||||
|
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
|
||||||
|
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<JobStatusHistory> JobStatusHistory =>
|
public IRepository<JobStatusHistory> JobStatusHistory =>
|
||||||
@@ -423,8 +439,8 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
|
_dashboardTips ??= new PlainRepository<DashboardTip>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<InAppNotification> InAppNotifications =>
|
public IInAppNotificationRepository InAppNotifications =>
|
||||||
_inAppNotifications ??= new Repository<InAppNotification>(_context);
|
_inAppNotifications ??= new InAppNotificationRepository(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
|
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
|
||||||
public IPlainRepository<ReleaseNote> ReleaseNotes =>
|
public IPlainRepository<ReleaseNote> ReleaseNotes =>
|
||||||
@@ -460,10 +476,30 @@ 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="EmployeeClockEntry"/> facility-level clock-in/clock-out records; tenant-filtered with soft delete. Multiple entries per day are fully supported.</summary>
|
||||||
|
public IRepository<EmployeeClockEntry> EmployeeClockEntries =>
|
||||||
|
_employeeClockEntries ??= new Repository<EmployeeClockEntry>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="TimeclockKioskDevice"/> activated tablet records; one row per device. Delete a row to revoke that device's access.</summary>
|
||||||
|
public IRepository<TimeclockKioskDevice> TimeclockKioskDevices =>
|
||||||
|
_timeclockKioskDevices ??= new Repository<TimeclockKioskDevice>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<CustomItemTemplate> CustomItemTemplates =>
|
public IRepository<CustomItemTemplate> CustomItemTemplates =>
|
||||||
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
|
_customItemTemplates ??= new Repository<CustomItemTemplate>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="FormulaLibraryItem"/> community library entries; platform-level, no tenant filter.</summary>
|
||||||
|
public IPlainRepository<FormulaLibraryItem> FormulaLibrary =>
|
||||||
|
_formulaLibrary ??= new PlainRepository<FormulaLibraryItem>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="FormulaLibraryImport"/> per-company import records; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<FormulaLibraryImport> FormulaLibraryImports =>
|
||||||
|
_formulaLibraryImports ??= new Repository<FormulaLibraryImport>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="FormulaLibraryRating"/> per-company thumbs votes; platform-level, no tenant filter.</summary>
|
||||||
|
public IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings =>
|
||||||
|
_formulaLibraryRatings ??= new PlainRepository<FormulaLibraryRating>(_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 =>
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
JobNumber = "JOB-2601-0001",
|
JobNumber = "JOB-2601-0001",
|
||||||
CustomerEmail = "customer@example.com",
|
CustomerEmail = "customer@example.com",
|
||||||
CustomerName = "Acme Corp (used if email is blank or not found)",
|
CustomerName = "Acme Corp (used if email is blank or not found)",
|
||||||
|
Description = "Sample job description",
|
||||||
Status = "Pending",
|
Status = "Pending",
|
||||||
Priority = "Normal",
|
Priority = "Normal",
|
||||||
ScheduledDate = DateTime.Today.AddDays(7),
|
ScheduledDate = DateTime.Today.AddDays(7),
|
||||||
@@ -269,7 +270,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
FinalPrice = 750.00m,
|
FinalPrice = 750.00m,
|
||||||
CustomerPO = "PO-12345",
|
CustomerPO = "PO-12345",
|
||||||
SpecialInstructions = "Handle with care",
|
SpecialInstructions = "Handle with care",
|
||||||
Notes = "Sample job"
|
Notes = "Internal notes"
|
||||||
});
|
});
|
||||||
csv.NextRecord();
|
csv.NextRecord();
|
||||||
|
|
||||||
@@ -388,8 +389,15 @@ public class CsvImportService : ICsvImportService
|
|||||||
/// Imports customers from a CSV stream and persists valid rows to the database for the given company.
|
/// Imports customers from a CSV stream and persists valid rows to the database for the given company.
|
||||||
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
|
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
|
||||||
/// entity is saved individually so that a single bad row does not roll back the entire batch.
|
/// entity is saved individually so that a single bad row does not roll back the entire batch.
|
||||||
/// Duplicate detection runs against both existing DB records (by email) and within the import file
|
/// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous
|
||||||
/// itself, catching cases where the same email appears twice in one upload.
|
/// identifier is absent:
|
||||||
|
/// Tier 1 — email address (case-insensitive): if email is present and matches a DB record or
|
||||||
|
/// earlier batch row the row is skipped.
|
||||||
|
/// Tier 2 — name + normalised phone composite: used when email is absent. Combining name with
|
||||||
|
/// phone prevents false positives when two people share a number (e.g. a family).
|
||||||
|
/// Row is skipped on match.
|
||||||
|
/// Tier 3 — name + city/state/zip composite: used when both email and phone are absent.
|
||||||
|
/// Location data is imprecise so this emits a warning but still imports the row.
|
||||||
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
|
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
|
||||||
/// customer is imported without a tier rather than being skipped entirely.
|
/// customer is imported without a tier rather than being skipped entirely.
|
||||||
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a
|
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a
|
||||||
@@ -418,15 +426,53 @@ public class CsvImportService : ICsvImportService
|
|||||||
|
|
||||||
// Get all existing customers for duplicate detection
|
// Get all existing customers for duplicate detection
|
||||||
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
|
|
||||||
|
// Tier 1 lookup: email → existing customer
|
||||||
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
|
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||||
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Tier 2 lookup: (normalised phone + "|" + display name) → existing customer.
|
||||||
|
// Combining name with phone avoids false positives when two people share a number.
|
||||||
|
var existingByPhoneAndName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in existingCustomers)
|
||||||
|
{
|
||||||
|
var phone = NormalizePhone(c.MobilePhone) ?? NormalizePhone(c.Phone);
|
||||||
|
if (phone == null) continue;
|
||||||
|
var name = string.IsNullOrWhiteSpace(c.CompanyName)
|
||||||
|
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||||||
|
: c.CompanyName;
|
||||||
|
var key = $"{phone}|{name}";
|
||||||
|
if (!existingByPhoneAndName.ContainsKey(key))
|
||||||
|
existingByPhoneAndName[key] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3 lookup: (display name + "|" + city + "|" + state + "|" + zip) → existing customer.
|
||||||
|
// Only populated when a customer has at least one location field so the key isn't trivially blank.
|
||||||
|
var existingByNameAndLocation = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in existingCustomers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(c.City) && string.IsNullOrWhiteSpace(c.State) && string.IsNullOrWhiteSpace(c.ZipCode))
|
||||||
|
continue;
|
||||||
|
var name = string.IsNullOrWhiteSpace(c.CompanyName)
|
||||||
|
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||||||
|
: c.CompanyName;
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
var key = $"{name}|{c.City}|{c.State}|{c.ZipCode}";
|
||||||
|
if (!existingByNameAndLocation.ContainsKey(key))
|
||||||
|
existingByNameAndLocation[key] = c;
|
||||||
|
}
|
||||||
|
|
||||||
// Get pricing tiers for lookup
|
// Get pricing tiers for lookup
|
||||||
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
||||||
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
|
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
|
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
|
||||||
|
|
||||||
|
// Within-batch tracking sets (prevent duplicate detection against rows already queued)
|
||||||
|
var batchEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var batchPhoneAndName = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var batchNameAndLocation = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
{
|
{
|
||||||
rowNumber++;
|
rowNumber++;
|
||||||
@@ -434,7 +480,12 @@ public class CsvImportService : ICsvImportService
|
|||||||
{
|
{
|
||||||
// Strip any literal quote characters that QB/Excel may wrap around field values
|
// Strip any literal quote characters that QB/Excel may wrap around field values
|
||||||
var cleanCompanyName = StripQuotes(record.CompanyName);
|
var cleanCompanyName = StripQuotes(record.CompanyName);
|
||||||
var cleanEmail = StripQuotes(record.Email);
|
// Normalise to null (not empty string) — the UNIQUE index on (CompanyId, Email)
|
||||||
|
// uses HasFilter("[Email] IS NOT NULL"), so NULL is allowed for multiple rows
|
||||||
|
// but "" (empty string) is not NULL and would cause a unique-constraint violation
|
||||||
|
// on the second blank-email customer saved.
|
||||||
|
var rawEmail = StripQuotes(record.Email);
|
||||||
|
var cleanEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail;
|
||||||
var firstName = StripQuotes(record.ContactFirstName)?.Trim();
|
var firstName = StripQuotes(record.ContactFirstName)?.Trim();
|
||||||
var lastName = StripQuotes(record.ContactLastName)?.Trim();
|
var lastName = StripQuotes(record.ContactLastName)?.Trim();
|
||||||
|
|
||||||
@@ -451,20 +502,68 @@ public class CsvImportService : ICsvImportService
|
|||||||
cleanCompanyName = derivedName;
|
cleanCompanyName = derivedName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate email in existing data
|
// Canonical display name used as part of composite keys in Tiers 2 and 3
|
||||||
if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower()))
|
var displayName = string.IsNullOrWhiteSpace(cleanCompanyName)
|
||||||
{
|
? $"{firstName} {lastName}".Trim()
|
||||||
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
|
: cleanCompanyName;
|
||||||
result.SkippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate email within the import batch
|
// --- Tier 1: email dedup ---
|
||||||
if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase)))
|
// Only engaged when the row has an email address.
|
||||||
|
if (!string.IsNullOrEmpty(cleanEmail))
|
||||||
{
|
{
|
||||||
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
|
if (existingEmails.ContainsKey(cleanEmail.ToLower()))
|
||||||
result.SkippedCount++;
|
{
|
||||||
continue;
|
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (batchEmails.Contains(cleanEmail))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// --- Tier 2: name + phone composite dedup (email absent) ---
|
||||||
|
// Requiring both name and phone to match avoids false positives when two
|
||||||
|
// unrelated people happen to share a phone number (e.g. a shared office line).
|
||||||
|
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
|
||||||
|
if (normalizedPhone != null)
|
||||||
|
{
|
||||||
|
var phoneNameKey = $"{normalizedPhone}|{displayName}";
|
||||||
|
if (existingByPhoneAndName.TryGetValue(phoneNameKey, out var existingMatch))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; name + phone matches existing customer '{existingMatch.CompanyName}'. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (batchPhoneAndName.Contains(phoneNameKey))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; duplicate name + phone found in import file. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// --- Tier 3: name + location composite warning (no email, no phone) ---
|
||||||
|
// Location data is imprecise so we warn but still import — a name + city
|
||||||
|
// collision across unrelated people is plausible enough not to hard-skip.
|
||||||
|
var city = record.City?.Trim();
|
||||||
|
var state = record.State?.Trim();
|
||||||
|
var zip = record.ZipCode?.Trim();
|
||||||
|
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
|
||||||
|
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
var locationKey = $"{displayName}|{city}|{state}|{zip}";
|
||||||
|
if (existingByNameAndLocation.ContainsKey(locationKey) || batchNameAndLocation.Contains(locationKey))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email or phone; name + location matches an existing record. Imported anyway — verify manually.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve pricing tier
|
// Resolve pricing tier
|
||||||
@@ -506,12 +605,41 @@ public class CsvImportService : ICsvImportService
|
|||||||
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
|
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
|
||||||
IsTaxExempt = record.TaxExempt ?? false,
|
IsTaxExempt = record.TaxExempt ?? false,
|
||||||
GeneralNotes = record.Notes?.Trim(),
|
GeneralNotes = record.Notes?.Trim(),
|
||||||
|
LeadSource = record.LeadSource?.Trim(),
|
||||||
|
ShipToAddress = record.ShipToAddress?.Trim(),
|
||||||
|
ShipToCity = record.ShipToCity?.Trim(),
|
||||||
|
ShipToState = record.ShipToState?.Trim(),
|
||||||
|
ShipToZipCode = record.ShipToZipCode?.Trim(),
|
||||||
|
ShipToCountry = record.ShipToCountry?.Trim(),
|
||||||
IsActive = record.IsActive ?? true,
|
IsActive = record.IsActive ?? true,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
|
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
|
||||||
|
|
||||||
|
// Register in batch tracking so later rows are checked against this one
|
||||||
|
if (!string.IsNullOrEmpty(cleanEmail))
|
||||||
|
{
|
||||||
|
batchEmails.Add(cleanEmail);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
|
||||||
|
if (normalizedPhone != null)
|
||||||
|
{
|
||||||
|
batchPhoneAndName.Add($"{normalizedPhone}|{displayName}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var city = record.City?.Trim();
|
||||||
|
var state = record.State?.Trim();
|
||||||
|
var zip = record.ZipCode?.Trim();
|
||||||
|
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
|
||||||
|
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
|
||||||
|
batchNameAndLocation.Add($"{displayName}|{city}|{state}|{zip}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1162,6 +1290,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
Total = record.Total,
|
Total = record.Total,
|
||||||
Notes = record.Notes?.Trim(),
|
Notes = record.Notes?.Trim(),
|
||||||
Terms = record.TermsAndConditions?.Trim(),
|
Terms = record.TermsAndConditions?.Trim(),
|
||||||
|
ProjectName = record.ProjectName?.Trim(),
|
||||||
IsCommercial = customerId.HasValue,
|
IsCommercial = customerId.HasValue,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
@@ -1268,24 +1397,22 @@ public class CsvImportService : ICsvImportService
|
|||||||
MissingFieldFound = null
|
MissingFieldFound = null
|
||||||
});
|
});
|
||||||
|
|
||||||
var records = csv.GetRecords<JobImportDto>().ToList();
|
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
|
||||||
result.TotalRows = records.Count;
|
// as null rather than throwing a fatal TypeConverterException.
|
||||||
|
csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());
|
||||||
|
|
||||||
_logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId);
|
// Read header row first so we know field count before iterating rows.
|
||||||
|
await csv.ReadAsync();
|
||||||
|
csv.ReadHeader();
|
||||||
|
|
||||||
// Get all existing jobs for duplicate detection
|
// Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
|
||||||
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
|
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||||
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||||
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Get customers for lookup — build two dictionaries so we can resolve by email
|
|
||||||
// first and fall back to company name when the customer has no email on file.
|
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||||
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
||||||
// Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
|
|
||||||
// TryAdd ensures that if two customers share the same name the first one wins and the
|
|
||||||
// lookup warning will prompt the user to resolve the ambiguity manually.
|
|
||||||
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var c in customers)
|
foreach (var c in customers)
|
||||||
{
|
{
|
||||||
@@ -1296,19 +1423,42 @@ public class CsvImportService : ICsvImportService
|
|||||||
customerByName.TryAdd(name, c);
|
customerByName.TryAdd(name, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get job statuses for lookup
|
|
||||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||||
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Get job priorities for lookup
|
|
||||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||||
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
|
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
|
||||||
|
|
||||||
foreach (var record in records)
|
// Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
|
||||||
|
// in a decimal field) is caught per-row rather than aborting the entire import.
|
||||||
|
while (await csv.ReadAsync())
|
||||||
{
|
{
|
||||||
rowNumber++;
|
result.TotalRows++;
|
||||||
|
JobImportDto record;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
record = csv.GetRecord<JobImportDto>()
|
||||||
|
?? throw new InvalidOperationException("Row returned null record.");
|
||||||
|
}
|
||||||
|
catch (Exception parseEx)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
_logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
|
||||||
|
|
||||||
|
// Warn when FinalPrice was non-numeric (lenient converter returned null).
|
||||||
|
var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
|
||||||
|
&& !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -1414,7 +1564,10 @@ public class CsvImportService : ICsvImportService
|
|||||||
CustomerPO = record.CustomerPO?.Trim(),
|
CustomerPO = record.CustomerPO?.Trim(),
|
||||||
SpecialInstructions = record.SpecialInstructions?.Trim(),
|
SpecialInstructions = record.SpecialInstructions?.Trim(),
|
||||||
InternalNotes = record.Notes?.Trim(),
|
InternalNotes = record.Notes?.Trim(),
|
||||||
Description = record.SpecialInstructions?.Trim() ?? "Imported job",
|
ProjectName = record.ProjectName?.Trim(),
|
||||||
|
Description = record.Description?.Trim()
|
||||||
|
?? record.SpecialInstructions?.Trim()
|
||||||
|
?? "Imported job",
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
@@ -2813,6 +2966,23 @@ public class CsvImportService : ICsvImportService
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalises a phone string to its last 10 digits for duplicate-detection comparisons.
|
||||||
|
/// Stripping to digits only means formatting differences such as (423) 331-9834,
|
||||||
|
/// 423-331-9834, and 4233319834 all produce the same key. Returns null when the input
|
||||||
|
/// contains fewer than 7 digits — too short to be a real phone number and avoids false
|
||||||
|
/// positive matches on placeholder values like "N/A" or extension-only strings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="phone">Raw phone string as read from the CSV, or null.</param>
|
||||||
|
/// <returns>Last 10 (or all, if fewer than 10) digits of the input; null if input is unusable.</returns>
|
||||||
|
private static string? NormalizePhone(string? phone)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(phone)) return null;
|
||||||
|
var digits = new string(phone.Where(char.IsDigit).ToArray());
|
||||||
|
if (digits.Length < 7) return null;
|
||||||
|
return digits.Length >= 10 ? digits[^10..] : digits;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Invoice Import ───────────────────────────────────────────────────────────
|
// ── Invoice Import ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2984,9 +3154,10 @@ public class CsvImportService : ICsvImportService
|
|||||||
existing.DiscountAmount = record.DiscountAmount;
|
existing.DiscountAmount = record.DiscountAmount;
|
||||||
existing.Total = record.Total;
|
existing.Total = record.Total;
|
||||||
existing.AmountPaid = record.AmountPaid;
|
existing.AmountPaid = record.AmountPaid;
|
||||||
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
|
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
|
||||||
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
|
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
|
||||||
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
|
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
|
||||||
|
existing.ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim();
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
result.SuccessCount++;
|
result.SuccessCount++;
|
||||||
}
|
}
|
||||||
@@ -3008,9 +3179,10 @@ public class CsvImportService : ICsvImportService
|
|||||||
DiscountAmount = record.DiscountAmount,
|
DiscountAmount = record.DiscountAmount,
|
||||||
Total = record.Total,
|
Total = record.Total,
|
||||||
AmountPaid = record.AmountPaid,
|
AmountPaid = record.AmountPaid,
|
||||||
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
|
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
|
||||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(),
|
||||||
|
ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim()
|
||||||
};
|
};
|
||||||
await _unitOfWork.Invoices.AddAsync(invoice);
|
await _unitOfWork.Invoices.AddAsync(invoice);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -3340,4 +3512,23 @@ public class CsvImportService : ICsvImportService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
|
||||||
|
/// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
|
||||||
|
/// artefacts like "false" in a price column are treated as $0 with a warning.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
|
||||||
|
{
|
||||||
|
public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
|
||||||
|
? (object?)v
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||||
|
=> value?.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
using NCalc2;
|
using NCalc2;
|
||||||
using Anthropic.SDK;
|
using Anthropic.SDK;
|
||||||
using Anthropic.SDK.Messaging;
|
using Anthropic.SDK.Messaging;
|
||||||
@@ -24,8 +25,22 @@ public class CustomFormulaAiService : ICustomFormulaAiService
|
|||||||
|
|
||||||
private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business.
|
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
|
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()),
|
from user-supplied field values.
|
||||||
comparison operators, and the Abs(), Round(), Max(), Min() built-in functions.
|
|
||||||
|
CRITICAL: NCalc function names are CASE-SENSITIVE and must be ALL LOWERCASE.
|
||||||
|
Supported built-in functions (always write these exactly as shown):
|
||||||
|
if(condition, trueValue, falseValue) — conditional expression
|
||||||
|
abs(x) — absolute value
|
||||||
|
round(x, digits) — round to N decimal places
|
||||||
|
max(a, b) — larger of two values
|
||||||
|
min(a, b) — smaller of two values
|
||||||
|
pow(base, exponent) — exponentiation
|
||||||
|
sqrt(x) — square root
|
||||||
|
Standard operators: + - * / %
|
||||||
|
Comparison operators: < > <= >= == !=
|
||||||
|
Boolean operators: && || !
|
||||||
|
|
||||||
|
Do NOT use: IF, Abs, Round, Max, Min, Pow, Sqrt (uppercase versions) — NCalc will reject them.
|
||||||
|
|
||||||
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
|
The user will describe a custom fabricated item (e.g., 'Roof curb', 'Electrical enclosure',
|
||||||
'Tubular frame') and you must produce a pricing formula template.
|
'Tubular frame') and you must produce a pricing formula template.
|
||||||
@@ -51,11 +66,17 @@ Respond ONLY with a valid JSON object matching this exact schema — no markdown
|
|||||||
""verificationResult"": number
|
""verificationResult"": number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Built-in shop-rate variables (injected automatically at eval time — do NOT redeclare them as fields):
|
||||||
|
standard_labor_rate — shop billing rate in $/hr (e.g. hours * standard_labor_rate)
|
||||||
|
additional_coat_labor_pct — extra-coat labor surcharge 0–100 (e.g. cost * (1 + additional_coat_labor_pct/100))
|
||||||
|
markup_pct — general markup percentage 0–100 (e.g. cost * (1 + markup_pct/100))
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
|
- 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
|
- 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
|
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions, UNLESS the formula already uses standard_labor_rate or another built-in
|
||||||
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
|
- Field names must be valid NCalc identifiers (letters, digits, underscores; no spaces or hyphens)
|
||||||
|
- Do NOT include standard_labor_rate, additional_coat_labor_pct, or markup_pct in the fields array — they are injected automatically
|
||||||
- verificationInputs and verificationResult must use the exact field names and formula you generated
|
- 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
|
- 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
|
- For inch inputs convert to sqft: divide by 144 (sqin→sqft) or use /12 per side before multiplying
|
||||||
@@ -166,6 +187,35 @@ Rules:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lowercase NCalc built-in names before evaluation so that user-typed or AI-generated
|
||||||
|
// uppercase variants (IF, Abs, POW, etc.) don't produce "Function not found" errors.
|
||||||
|
private static readonly Regex _ncalcFuncRegex = new(
|
||||||
|
@"\b(if|abs|round|max|min|pow|sqrt|ceiling|floor|truncate|sign|log|exp)\b(?=\s*\()",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static string NormalizeFormula(string formula) =>
|
||||||
|
_ncalcFuncRegex.Replace(formula, m => m.Value.ToLowerInvariant());
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public (string NormalizedFormula, string? Error) NormalizeAndValidate(string formula)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(formula))
|
||||||
|
return (formula, "Formula cannot be empty.");
|
||||||
|
|
||||||
|
var normalized = NormalizeFormula(formula);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var expr = new Expression(normalized);
|
||||||
|
if (expr.HasErrors())
|
||||||
|
return (formula, expr.Error);
|
||||||
|
return (normalized, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
return (formula, ex.Message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
||||||
{
|
{
|
||||||
@@ -177,11 +227,11 @@ Rules:
|
|||||||
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
||||||
request.VariablesJson ?? "{}") ?? new();
|
request.VariablesJson ?? "{}") ?? new();
|
||||||
|
|
||||||
var expr = new Expression(request.Formula);
|
var expr = new Expression(NormalizeFormula(request.Formula));
|
||||||
foreach (var kv in variables)
|
foreach (var kv in variables)
|
||||||
{
|
{
|
||||||
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
||||||
? (object)kv.Value.GetDecimal()
|
? (object)kv.Value.GetDouble()
|
||||||
: (object)(kv.Value.GetString() ?? "");
|
: (object)(kv.Value.GetString() ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ public class EmailService : IEmailService
|
|||||||
if (!_hostEnvironment.IsProduction())
|
if (!_hostEnvironment.IsProduction())
|
||||||
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
||||||
|
|
||||||
|
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
|
||||||
|
|
||||||
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
||||||
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
|
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
|
||||||
|
|
||||||
@@ -104,6 +106,8 @@ public class EmailService : IEmailService
|
|||||||
if (!_hostEnvironment.IsProduction())
|
if (!_hostEnvironment.IsProduction())
|
||||||
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
||||||
|
|
||||||
|
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
|
||||||
|
|
||||||
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
||||||
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
|
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
|
||||||
|
|
||||||
@@ -138,6 +142,20 @@ public class EmailService : IEmailService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In non-production environments, redirects outbound email to <c>SendGrid:DevRedirectEmail</c>
|
||||||
|
/// so real customers are never contacted outside of production. Double-gated on environment
|
||||||
|
/// name AND the config value so a misconfigured prod deploy can't accidentally redirect.
|
||||||
|
/// </summary>
|
||||||
|
private (string email, string name) RedirectIfNonProd(string toEmail, string toName)
|
||||||
|
{
|
||||||
|
if (_hostEnvironment.IsProduction()) return (toEmail, toName);
|
||||||
|
var devEmail = _configuration["SendGrid:DevRedirectEmail"];
|
||||||
|
if (string.IsNullOrWhiteSpace(devEmail)) return (toEmail, toName);
|
||||||
|
_logger.LogWarning("Non-production environment: redirecting email from {Original} to dev address {Dev}", toEmail, devEmail);
|
||||||
|
return (devEmail, $"[DEV → {toName} <{toEmail}>]");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends the built SendGrid message and interprets the HTTP response. Extracted so both
|
/// Sends the built SendGrid message and interprets the HTTP response. Extracted so both
|
||||||
/// send methods share identical dispatch and logging logic.
|
/// send methods share identical dispatch and logging logic.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user