Compare commits
89 Commits
19b7a9a473
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 4650ba3d4d | |||
| 1eba50cf0f | |||
| b7ab85ff92 | |||
| ce7b00b68c | |||
| c5c1244177 | |||
| 25140554ad | |||
| 46cadea367 | |||
| cfe937c0c3 | |||
| 3ad6b0d08f | |||
| fdac0240d1 |
@@ -0,0 +1,11 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
NCalc2 2.1.0 -> Antlr4 4.6.4 -> Antlr4.Runtime -> NETStandard.Library 1.6.0 pulls in
|
||||
old package versions that trigger NU1605 downgrade warnings when publishing for linux-x64.
|
||||
These are harmless false positives — .NET 8 supplies all of these natively at runtime.
|
||||
Suppressing NU1605 here is cleaner than pinning every affected transitive package individually.
|
||||
-->
|
||||
<NoWarn>$(NoWarn);NU1605</NoWarn>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,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>
|
||||
public bool HasCurrentSmsAgreement { get; set; }
|
||||
public string SmsTermsVersion { get; set; } = string.Empty;
|
||||
|
||||
// Timeclock settings
|
||||
public bool TimeclockEnabled { get; set; }
|
||||
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||
public int? TimeclockAutoClockOutHours { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
|
||||
public class UpdateTimeclockSettingsDto
|
||||
{
|
||||
public bool TimeclockEnabled { get; set; }
|
||||
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
|
||||
public int? TimeclockAutoClockOutHours { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Company;
|
||||
|
||||
// ============================================================================
|
||||
// LIST DTO - For Company Settings tab table
|
||||
// ============================================================================
|
||||
public class CustomItemTemplateListDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public int FieldCount { get; set; }
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FULL DTO - For Edit modal and formula evaluation
|
||||
// ============================================================================
|
||||
public class CustomItemTemplateDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CREATE DTO
|
||||
// ============================================================================
|
||||
public class CreateCustomItemTemplateDto
|
||||
{
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"FixedRate" or "SurfaceAreaSqFt"</summary>
|
||||
[Required]
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||
[Required]
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// UPDATE DTO
|
||||
// ============================================================================
|
||||
public class UpdateCustomItemTemplateDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
[Required]
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>Existing diagram path — kept if no new file is uploaded.</summary>
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// WIZARD PICKER DTO - Lean DTO for populating the quote wizard template list
|
||||
// ============================================================================
|
||||
public class CustomItemTemplatePickerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// AI GENERATION DTOs
|
||||
// ============================================================================
|
||||
public class GenerateFormulaFromAiRequest
|
||||
{
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GenerateFormulaFromAiResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public string? OutputMode { get; set; }
|
||||
public string? FieldsJson { get; set; }
|
||||
public string? Formula { get; set; }
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Reasoning { get; set; }
|
||||
|
||||
/// <summary>Result of running the formula with any sample values found in the description.</summary>
|
||||
public decimal? VerificationResult { get; set; }
|
||||
public string? VerificationInputs { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// FORMULA EVALUATION DTOs
|
||||
// ============================================================================
|
||||
public class EvaluateFormulaRequest
|
||||
{
|
||||
[Required]
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>JSON object of variable name → value pairs, e.g. {"box_l": 43, "rate": 0.05}</summary>
|
||||
[Required]
|
||||
public string VariablesJson { get; set; } = "{}";
|
||||
}
|
||||
|
||||
public class EvaluateFormulaResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public decimal? Result { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace PowderCoating.Application.DTOs.Company;
|
||||
|
||||
// ── Browse / card display ──────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Lean DTO for the community library browse grid card.</summary>
|
||||
public class FormulaLibraryCardDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
public string? Tags { get; set; }
|
||||
public string? IndustryHint { get; set; }
|
||||
public string SourceCompanyName { get; set; } = string.Empty;
|
||||
public int ImportCount { get; set; }
|
||||
public DateTime SharedAt { get; set; }
|
||||
public string? DiagramImagePath { get; set; }
|
||||
|
||||
/// <summary>Non-null when this formula was derived from another library entry.</summary>
|
||||
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||
public string? InspiredByName { get; set; }
|
||||
public string? InspiredByCompanyName { get; set; }
|
||||
|
||||
/// <summary>True when the current company has already imported this entry.</summary>
|
||||
public bool AlreadyImported { get; set; }
|
||||
|
||||
/// <summary>True when this formula was shared by the current browsing company.</summary>
|
||||
public bool IsOwnFormula { get; set; }
|
||||
|
||||
/// <summary>Total thumbs-up votes across all companies.</summary>
|
||||
public int ThumbsUp { get; set; }
|
||||
|
||||
/// <summary>Total thumbs-down votes across all companies.</summary>
|
||||
public int ThumbsDown { get; set; }
|
||||
|
||||
/// <summary>The current browsing company's vote: true = up, false = down, null = no vote.</summary>
|
||||
public bool? MyVote { get; set; }
|
||||
}
|
||||
|
||||
// ── Full detail (import preview modal) ────────────────────────────────────
|
||||
|
||||
/// <summary>Full DTO used in the import preview modal — shows fields and formula.</summary>
|
||||
public class FormulaLibraryDetailDto : FormulaLibraryCardDto
|
||||
{
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
public decimal? DefaultRate { get; set; }
|
||||
public string? RateLabel { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int FieldCount { get; set; }
|
||||
}
|
||||
|
||||
// ── Share from Company Settings ───────────────────────────────────────────
|
||||
|
||||
/// <summary>Submitted when a company admin shares one of their templates to the community library.</summary>
|
||||
public class ShareFormulaRequest
|
||||
{
|
||||
public int CustomItemTemplateId { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
public string? IndustryHint { get; set; }
|
||||
}
|
||||
|
||||
// ── Company Settings list view ─────────────────────────────────────────────
|
||||
|
||||
/// <summary>Status of a template relative to the community library, shown in Company Settings.</summary>
|
||||
public class FormulaLibraryStatusDto
|
||||
{
|
||||
/// <summary>The FormulaLibraryItem Id, if this template has ever been shared.</summary>
|
||||
public int? LibraryItemId { get; set; }
|
||||
public bool IsPublished { get; set; }
|
||||
|
||||
/// <summary>Whether this template is eligible to be shared (original or modified import).</summary>
|
||||
public bool CanShare { get; set; }
|
||||
|
||||
/// <summary>Set when this template was imported; the name of the original library entry.</summary>
|
||||
public string? ImportedFromName { get; set; }
|
||||
public string? ImportedFromCompany { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
public class CustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
public string? LastName { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? ContactRole { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? MobilePhone { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public string DisplayName => string.IsNullOrWhiteSpace(LastName) ? FirstName : $"{FirstName} {LastName}";
|
||||
}
|
||||
|
||||
public class CreateCustomerContactDto
|
||||
{
|
||||
[Required(ErrorMessage = "First name is required.")]
|
||||
[StringLength(100)]
|
||||
[Display(Name = "First Name")]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Last Name")]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
[StringLength(100)]
|
||||
[Display(Name = "Job Title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
[StringLength(50)]
|
||||
[Display(Name = "Role")]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[EmailAddress]
|
||||
[StringLength(200)]
|
||||
[Display(Name = "Email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[Phone]
|
||||
[StringLength(20)]
|
||||
[Display(Name = "Mobile Phone")]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
[Display(Name = "Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateCustomerContactDto : CreateCustomerContactDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CustomerId { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace PowderCoating.Application.DTOs.Customer;
|
||||
|
||||
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
|
||||
public class CustomerTimelineEventDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
public string Icon { get; set; } = string.Empty;
|
||||
public string BadgeColor { get; set; } = string.Empty;
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Subtitle { get; set; }
|
||||
public decimal? Amount { get; set; }
|
||||
public int? EntityId { get; set; }
|
||||
public string? LinkController { get; set; }
|
||||
public string? LinkAction { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
|
||||
public class CustomerLifetimeStatsDto
|
||||
{
|
||||
public int TotalJobs { get; set; }
|
||||
public int ActiveJobs { get; set; }
|
||||
/// <summary>Sum of Total on non-voided invoices.</summary>
|
||||
public decimal TotalRevenue { get; set; }
|
||||
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
|
||||
public decimal TotalCollected { get; set; }
|
||||
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
|
||||
public decimal AverageJobValue { get; set; }
|
||||
public DateTime? LastJobDate { get; set; }
|
||||
public int? DaysSinceLastJob { get; set; }
|
||||
public int TotalQuotes { get; set; }
|
||||
public int TotalInvoices { get; set; }
|
||||
public decimal OpenBalance { get; set; }
|
||||
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
|
||||
public int? LastJobId { get; set; }
|
||||
}
|
||||
@@ -36,6 +36,16 @@ public class CustomerDto
|
||||
public bool NotifyBySms { get; set; }
|
||||
public DateTime? SmsConsentedAt { get; set; }
|
||||
public string? SmsConsentMethod { get; set; }
|
||||
|
||||
// CRM
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to address
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
}
|
||||
|
||||
public class CreateCustomerDto : IValidatableObject
|
||||
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
|
||||
[StringLength(2000)]
|
||||
public string? GeneralNotes { get; set; }
|
||||
|
||||
[Display(Name = "How did you find us?")]
|
||||
[StringLength(100)]
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address
|
||||
[Display(Name = "Ship-To Street Address")]
|
||||
[StringLength(500)]
|
||||
public string? ShipToAddress { get; set; }
|
||||
|
||||
[Display(Name = "City")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCity { get; set; }
|
||||
|
||||
[Display(Name = "State")]
|
||||
[StringLength(50)]
|
||||
public string? ShipToState { get; set; }
|
||||
|
||||
[Display(Name = "Zip Code")]
|
||||
[StringLength(20)]
|
||||
public string? ShipToZipCode { get; set; }
|
||||
|
||||
[Display(Name = "Country")]
|
||||
[StringLength(100)]
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
[Display(Name = "Notify by Email")]
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
|
||||
|
||||
@@ -63,4 +63,22 @@ public class CustomerImportDto
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
[Name("LeadSource")]
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
[Name("ShipToAddress")]
|
||||
public string? ShipToAddress { get; set; }
|
||||
|
||||
[Name("ShipToCity")]
|
||||
public string? ShipToCity { get; set; }
|
||||
|
||||
[Name("ShipToState")]
|
||||
public string? ShipToState { get; set; }
|
||||
|
||||
[Name("ShipToZipCode")]
|
||||
public string? ShipToZipCode { get; set; }
|
||||
|
||||
[Name("ShipToCountry")]
|
||||
public string? ShipToCountry { get; set; }
|
||||
}
|
||||
|
||||
@@ -33,6 +33,9 @@ public class InvoiceImportDto
|
||||
[Name("DueDate")]
|
||||
public DateTime? DueDate { get; set; }
|
||||
|
||||
[Name("Project Name", "ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("SubTotal")]
|
||||
public decimal SubTotal { get; set; }
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@ public class JobImportDto
|
||||
[Name("CustomerName")]
|
||||
public string? CustomerName { get; set; }
|
||||
|
||||
// Optional short label for the job (maps directly to Job.Description).
|
||||
// When blank, the system falls back to SpecialInstructions, then "Imported job".
|
||||
[Name("Description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Name("Status")]
|
||||
public string Status { get; set; } = "Pending";
|
||||
|
||||
@@ -44,6 +49,9 @@ public class JobImportDto
|
||||
[Name("SpecialInstructions")]
|
||||
public string? SpecialInstructions { get; set; }
|
||||
|
||||
[Name("ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ public class QuoteImportDto
|
||||
[Name("ExpirationDate")]
|
||||
public DateTime? ExpirationDate { get; set; }
|
||||
|
||||
[Name("ProjectName")]
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Name("Subtotal")]
|
||||
public decimal Subtotal { get; set; }
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? ExternalReference { get; set; }
|
||||
public int? SalesTaxAccountId { get; set; }
|
||||
public string? SalesTaxAccountName { get; set; }
|
||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,7 @@ public class JobDto
|
||||
public decimal DiscountValue { get; set; }
|
||||
public string? DiscountReason { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
@@ -113,6 +114,8 @@ public class JobListDto
|
||||
|
||||
public string? CustomerEmail { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public DateTime? ScheduledDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
@@ -166,6 +169,7 @@ public class CreateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
@@ -251,6 +255,7 @@ public class UpdateJobDto
|
||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||
[Display(Name = "Customer PO")]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||
[Display(Name = "Special Instructions")]
|
||||
@@ -325,7 +330,11 @@ public class JobItemDto
|
||||
public bool IsGenericItem { get; set; }
|
||||
public bool IsLaborItem { get; set; }
|
||||
public bool IsSalesItem { get; set; }
|
||||
public bool IsAiItem { get; set; }
|
||||
public string? Sku { get; set; }
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
public List<JobItemCoatDto> Coats { get; set; } = new();
|
||||
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Items
|
||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
||||
[Display(Name = "Customer PO Number")]
|
||||
[StringLength(50)]
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
[Display(Name = "Tags")]
|
||||
[StringLength(500)]
|
||||
@@ -475,6 +478,11 @@ public class QuoteItemDto
|
||||
|
||||
public bool IsAiItem { get; set; }
|
||||
|
||||
// Custom formula item
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
|
||||
// Cost breakdown snapshot
|
||||
public decimal ItemMaterialCost { get; set; }
|
||||
public decimal ItemLaborCost { get; set; }
|
||||
@@ -559,6 +567,11 @@ public class CreateQuoteItemDto
|
||||
|
||||
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
||||
public int? AiPredictionId { get; set; }
|
||||
|
||||
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -874,4 +887,9 @@ public class QuotePricingResult
|
||||
|
||||
// Per-item results (same order as input items)
|
||||
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
||||
|
||||
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
|
||||
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
|
||||
public decimal CustomPowderOrderAmount { get; set; }
|
||||
public List<string> CustomPowderOrderColors { get; set; } = new();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
public bool AllowCustomFormulas { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
|
||||
public bool AllowAiInventoryAssist { get; set; }
|
||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||
public bool AllowSms { get; set; }
|
||||
public bool AllowCustomFormulas { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Timeclock;
|
||||
|
||||
public class EmployeeClockEntryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string UserDisplayName { get; set; } = string.Empty;
|
||||
public DateTime ClockInTime { get; set; }
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
public decimal? HoursWorked { get; set; }
|
||||
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||
public string? Notes { get; set; }
|
||||
public bool IsOpen => ClockOutTime == null;
|
||||
}
|
||||
|
||||
public class ClockInRequest
|
||||
{
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class ClockOutRequest
|
||||
{
|
||||
public int EntryId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
|
||||
/// The server determines whether to clock in or clock out based on the employee's open entry.
|
||||
/// </summary>
|
||||
public class KioskPunchRequest
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string Pin { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class EditClockEntryRequest
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateTime ClockInTime { get; set; }
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sent when an employee clicks Break or Lunch to pause their work segment.
|
||||
/// The server closes the current Work entry and opens a Break/Lunch entry.
|
||||
/// </summary>
|
||||
public class GoOnBreakRequest
|
||||
{
|
||||
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
|
||||
public ClockEntryType BreakType { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
|
||||
public class ManualEntryRequest
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public DateTime ClockInTime { get; set; }
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
|
||||
public class KioskEmployeeDto
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
public string Initials { get; set; } = string.Empty;
|
||||
/// <summary>True when the employee has an open clock entry right now.</summary>
|
||||
public bool IsClockedIn { get; set; }
|
||||
}
|
||||
@@ -18,7 +18,8 @@ public class WizardProgressDto
|
||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
||||
|
||||
public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
|
||||
// Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
|
||||
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
|
||||
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface ICustomFormulaAiService
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a NCalc formula, field list, and notes from a natural-language description
|
||||
/// and an optional diagram image. Returns a <see cref="GenerateFormulaFromAiResponse"/>
|
||||
/// ready to pre-fill the template editor.
|
||||
/// </summary>
|
||||
Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
|
||||
GenerateFormulaFromAiRequest request,
|
||||
byte[]? imageBytes = null,
|
||||
string? imageContentType = null);
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates a NCalc formula with the supplied variable map and returns the numeric result.
|
||||
/// Safe server-side only — no user-controlled code execution.
|
||||
/// </summary>
|
||||
EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes NCalc built-in function names to lowercase (IF→if, Abs→abs, etc.) then
|
||||
/// attempts a parse-only evaluation to catch syntax errors before the formula is saved.
|
||||
/// Returns the normalized formula string and a null error on success, or the original
|
||||
/// formula and an error message on failure.
|
||||
/// </summary>
|
||||
(string NormalizedFormula, string? Error) NormalizeAndValidate(string formula);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the community formula library: sharing, unsharing, importing, and browsing.
|
||||
/// </summary>
|
||||
public interface IFormulaLibraryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns all published library entries, with AlreadyImported populated for the given company.
|
||||
/// Optionally filters by search term, output mode, or industry hint.
|
||||
/// </summary>
|
||||
Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
|
||||
int companyId,
|
||||
string? search = null,
|
||||
string? outputMode = null,
|
||||
string? industryHint = null);
|
||||
|
||||
/// <summary>Full detail for the import preview modal, including field list and formula.</summary>
|
||||
Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Publishes a company template to the community library.
|
||||
/// If the template was previously shared and unpublished, re-publishes the existing row.
|
||||
/// Updates the library entry fields from the current template state on re-share.
|
||||
/// </summary>
|
||||
Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request);
|
||||
|
||||
/// <summary>Sets IsPublished = false. Existing imports are unaffected.</summary>
|
||||
Task UnshareAsync(int libraryItemId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Copies a library entry into the company's local CustomItemTemplate table.
|
||||
/// If the company already has an import record for this entry, returns the existing template id.
|
||||
/// </summary>
|
||||
Task<int> ImportAsync(int libraryItemId, int companyId, string userId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the library status for a given CustomItemTemplate — whether it is shared,
|
||||
/// eligible to be shared, and where it was imported from if applicable.
|
||||
/// </summary>
|
||||
Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Nulls out DiagramImagePath on the FormulaLibraryItem and all imported copies
|
||||
/// when a source template's diagram is removed. Call from CompanySettingsController
|
||||
/// when a diagram is deleted or replaced.
|
||||
/// </summary>
|
||||
Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId);
|
||||
|
||||
/// <summary>
|
||||
/// Records or toggles a thumbs-up/down vote from the given company.
|
||||
/// If the same vote already exists it is removed (toggle off).
|
||||
/// If the opposite vote exists it is replaced.
|
||||
/// Companies cannot rate their own formulas.
|
||||
/// Returns the updated counts for the library entry.
|
||||
/// </summary>
|
||||
Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
||||
int libraryItemId, int companyId, bool isPositive);
|
||||
}
|
||||
@@ -13,4 +13,12 @@ public interface IQuotePricingAssemblyService
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
|
||||
/// referenced by coats on the given quote, then links those coats to the new inventory records.
|
||||
/// Must be called after a quote transitions to Approved status.
|
||||
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
|
||||
/// </summary>
|
||||
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,19 @@ public class RemoveSeedDataOptions
|
||||
public bool Catalog { get; set; }
|
||||
public bool PricingTiers { 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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Mappings;
|
||||
|
||||
public class CustomItemTemplateProfile : Profile
|
||||
{
|
||||
public CustomItemTemplateProfile()
|
||||
{
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplateListDto>()
|
||||
.ForMember(dest => dest.FieldCount,
|
||||
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplateDto>();
|
||||
|
||||
CreateMap<CustomItemTemplate, CustomItemTemplatePickerDto>();
|
||||
|
||||
CreateMap<CreateCustomItemTemplateDto, CustomItemTemplate>();
|
||||
|
||||
CreateMap<UpdateCustomItemTemplateDto, CustomItemTemplate>()
|
||||
.ForMember(dest => dest.DiagramImagePath, opt => opt.Ignore()); // set by controller after blob upload
|
||||
|
||||
CreateMap<CustomItemTemplate, UpdateCustomItemTemplateDto>();
|
||||
}
|
||||
|
||||
private static int CountFields(string fieldsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||
? doc.RootElement.GetArrayLength()
|
||||
: 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
|
||||
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
|
||||
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
||||
: string.Empty));
|
||||
|
||||
// CustomerContact
|
||||
CreateMap<CustomerContact, CustomerContactDto>();
|
||||
CreateMap<CreateCustomerContactDto, CustomerContact>();
|
||||
CreateMap<UpdateCustomerContactDto, CustomerContact>()
|
||||
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
|
||||
CreateMap<CustomerContact, UpdateCustomerContactDto>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
|
||||
namespace PowderCoating.Application.Mappings;
|
||||
|
||||
public class FormulaLibraryProfile : Profile
|
||||
{
|
||||
public FormulaLibraryProfile()
|
||||
{
|
||||
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||
.ForMember(dest => dest.InspiredByName,
|
||||
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
|
||||
.ForMember(dest => dest.InspiredByCompanyName,
|
||||
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
|
||||
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
|
||||
|
||||
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
|
||||
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||
.ForMember(dest => dest.FieldCount,
|
||||
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||
}
|
||||
|
||||
private static int CountFields(string fieldsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||
? doc.RootElement.GetArrayLength()
|
||||
: 0;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
||||
|
||||
CreateMap<Invoice, InvoiceDto>()
|
||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||
? (s.Customer.IsCommercial
|
||||
? s.Customer.CompanyName
|
||||
|
||||
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
|
||||
.ReverseMap()
|
||||
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
|
||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
||||
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
||||
@@ -190,7 +192,10 @@ public class QuoteProfile : Profile
|
||||
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
||||
|
||||
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
||||
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
|
||||
// is unreliable for ICollection<T> → List<T2> with different element types.
|
||||
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
||||
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
|
||||
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||
|
||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||
|
||||
|
||||
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
AiPredictionId = source.AiPredictionId,
|
||||
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
Complexity = seed.Complexity,
|
||||
AiTags = seed.AiTags,
|
||||
AiPredictionId = seed.AiPredictionId,
|
||||
IsCustomFormulaItem = seed.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = seed.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
public string? Complexity { get; init; }
|
||||
public string? AiTags { get; init; }
|
||||
public int? AiPredictionId { get; init; }
|
||||
public bool IsCustomFormulaItem { get; init; }
|
||||
public int? CustomItemTemplateId { get; init; }
|
||||
public string? FormulaFieldValuesJson { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
|
||||
@@ -98,12 +98,7 @@ public class PdfService : IPdfService
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||
page.Content().Layers(layers =>
|
||||
{
|
||||
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
if (invoiceDto.Status == InvoiceStatus.Paid)
|
||||
layers.Layer().Element(c => ComposePaidStamp(c));
|
||||
});
|
||||
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber();
|
||||
@@ -148,8 +143,18 @@ public class PdfService : IPdfService
|
||||
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
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 =>
|
||||
{
|
||||
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>
|
||||
/// 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,
|
||||
@@ -217,6 +201,8 @@ public class PdfService : IPdfService
|
||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -609,6 +595,15 @@ public class PdfService : IPdfService
|
||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||
{
|
||||
column.Item().Row(row =>
|
||||
{
|
||||
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when a coat requires ordering custom powder that is not in inventory.
|
||||
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
|
||||
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
|
||||
/// </summary>
|
||||
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
|
||||
!coat.InventoryItemId.HasValue &&
|
||||
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
||||
/// path based on item type:
|
||||
@@ -288,6 +298,26 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
};
|
||||
}
|
||||
|
||||
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
|
||||
// and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
|
||||
// exactly like every other item type that uses ManualUnitPrice.
|
||||
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
|
||||
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
|
||||
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
var formulaUnitPrice = item.ManualUnitPrice.Value;
|
||||
var formulaTotal = formulaUnitPrice * item.Quantity;
|
||||
return new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = formulaTotal,
|
||||
UnitPrice = formulaUnitPrice,
|
||||
TotalPrice = formulaTotal
|
||||
};
|
||||
}
|
||||
|
||||
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
||||
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
||||
{
|
||||
@@ -312,6 +342,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
for (int i = 0; i < item.Coats.Count; i++)
|
||||
{
|
||||
// Custom powder material moves to the "Custom Powder Order" line item
|
||||
if (IsCustomPowderCoat(item.Coats[i])) continue;
|
||||
var coatResult = await CalculateCoatPriceAsync(
|
||||
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
||||
coatMaterialCost += coatResult.CoatMaterialCost;
|
||||
@@ -413,7 +445,9 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
for (int ci = 0; ci < item.Coats.Count; ci++)
|
||||
{
|
||||
var coat = item.Coats[ci];
|
||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
// Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
|
||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
|
||||
&& !IsCustomPowderCoat(coat))
|
||||
{
|
||||
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
||||
totalMaterialCost += coatResult.CoatMaterialCost;
|
||||
@@ -431,7 +465,8 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
||||
// Custom powder material moves to the "Custom Powder Order" line item; keep the labor
|
||||
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
|
||||
coatLaborCost = firstCoatResult.CoatLaborCost;
|
||||
totalLaborCost = coatLaborCost;
|
||||
}
|
||||
@@ -628,6 +663,49 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
// 4. TOTAL ITEMS SUBTOTAL
|
||||
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
||||
|
||||
// Powder-to-order costs are excluded from individual item prices and collected in a
|
||||
// "Custom Powder Order" line item added at save time. For live pricing previews (before
|
||||
// save), add them back here so the displayed total stays correct throughout the session.
|
||||
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
|
||||
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
|
||||
bool hasCustomPowderOrderItem = items.Any(i =>
|
||||
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
|
||||
decimal customPowderOrderAmount = 0m;
|
||||
var customPowderOrderColors = new List<string>();
|
||||
if (!hasCustomPowderOrderItem)
|
||||
{
|
||||
foreach (var item in items.Where(i => i.Coats != null))
|
||||
{
|
||||
foreach (var c in item.Coats!)
|
||||
{
|
||||
if (!c.InventoryItemId.HasValue &&
|
||||
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
|
||||
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
|
||||
if (!string.IsNullOrWhiteSpace(c.ColorName))
|
||||
customPowderOrderColors.Add(c.ColorName);
|
||||
}
|
||||
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
|
||||
{
|
||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
||||
if (invItem?.IsIncoming == true)
|
||||
{
|
||||
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
|
||||
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
||||
if (!string.IsNullOrWhiteSpace(colorName))
|
||||
customPowderOrderColors.Add(colorName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (customPowderOrderAmount > 0)
|
||||
{
|
||||
itemsSubtotal += customPowderOrderAmount;
|
||||
totalMaterialCosts += customPowderOrderAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
||||
// AI items already have oven cost baked into their AI-estimated price, so we only
|
||||
// charge the proportion of the oven that's attributable to non-AI items.
|
||||
@@ -806,7 +884,11 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
||||
LaborCosts = Math.Round(totalLaborCosts, 2),
|
||||
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
||||
ItemResults = itemResults
|
||||
ItemResults = itemResults,
|
||||
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
|
||||
CustomPowderOrderColors = customPowderOrderColors
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||
|
||||
var dtoList = itemDtos.ToList();
|
||||
var items = new List<QuoteItem>();
|
||||
foreach (var itemDto in itemDtos)
|
||||
foreach (var itemDto in dtoList)
|
||||
{
|
||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
// Option B: auto-create the Custom Powder Order item only on first save.
|
||||
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
|
||||
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
|
||||
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
|
||||
if (!hasExistingCustomPowderOrder)
|
||||
{
|
||||
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
|
||||
if (customPowderItem != null)
|
||||
items.Add(customPowderItem);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -130,6 +142,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
@@ -161,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
||||
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
|
||||
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
|
||||
/// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
|
||||
/// quote (deduplication). No inventory is created during quote save.
|
||||
/// </summary>
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
@@ -175,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
||||
// Incoming-inventory creation is intentionally deferred to quote approval.
|
||||
// PowderCatalogItemId is persisted on the coat entity for later use.
|
||||
|
||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
@@ -243,6 +264,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
IsAiItem = itemDto.IsAiItem,
|
||||
AiTags = itemDto.AiTags,
|
||||
AiPredictionId = itemDto.AiPredictionId,
|
||||
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
|
||||
CustomItemTemplateId = itemDto.CustomItemTemplateId,
|
||||
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
@@ -256,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
PowderCatalogItemId = coatDto.CatalogItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
@@ -305,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
||||
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
|
||||
/// Called at quote-approval time (not during quote save) so inventory records only appear
|
||||
/// when a job is actually going to be created. The caller groups coats by
|
||||
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
|
||||
/// duplicate records when the same powder appears on multiple items in the same quote.
|
||||
///
|
||||
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
||||
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
||||
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
|
||||
/// so the item always lands in the right bucket regardless of how many IsCoating categories
|
||||
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
|
||||
///
|
||||
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
||||
/// if it fails, the item is still created with whatever data the catalog has.
|
||||
///
|
||||
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
|
||||
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
|
||||
/// inventory unit cost rather than the now-stale manual price from the quote form.
|
||||
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
|
||||
/// from the manufacturer product page. Best-effort — item is still created from catalog data
|
||||
/// if the AI call fails.
|
||||
/// </summary>
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
||||
if (catalogItem == null) return null;
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
|
||||
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
|
||||
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||
?? categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
@@ -437,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
coatDto.PowderCostPerLb = null;
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
||||
item.Id, item.Name, coatDto.CatalogItemId);
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
|
||||
item.Id, item.Name, catalogItemId);
|
||||
|
||||
return item.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||
coatDto.CatalogItemId);
|
||||
catalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
|
||||
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
|
||||
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
|
||||
/// on the first save only — Option B means the user owns the price after creation.
|
||||
///
|
||||
/// Coat types that qualify:
|
||||
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered)
|
||||
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
|
||||
/// pre-filled from catalog unit price (inventory creation deferred to approval)
|
||||
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
|
||||
/// </summary>
|
||||
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
||||
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
var colorNames = new List<string>();
|
||||
decimal totalCost = 0m;
|
||||
|
||||
foreach (var itemDto in itemDtos)
|
||||
{
|
||||
if (itemDto.Coats == null) continue;
|
||||
foreach (var coat in itemDto.Coats)
|
||||
{
|
||||
if (!coat.InventoryItemId.HasValue &&
|
||||
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||
{
|
||||
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
|
||||
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
|
||||
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||
colorNames.Add(coat.ColorName);
|
||||
}
|
||||
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||
{
|
||||
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
|
||||
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
|
||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||
if (invItem?.IsIncoming == true)
|
||||
{
|
||||
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
||||
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
||||
if (!string.IsNullOrWhiteSpace(colorName))
|
||||
colorNames.Add(colorName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (totalCost <= 0) return null;
|
||||
|
||||
var uniqueColors = colorNames
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
var description = uniqueColors.Any()
|
||||
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
||||
: "Custom Powder Order";
|
||||
|
||||
return new QuoteItem
|
||||
{
|
||||
QuoteId = quoteId,
|
||||
Description = description,
|
||||
Quantity = 1,
|
||||
IsGenericItem = true,
|
||||
ManualUnitPrice = totalCost,
|
||||
UnitPrice = totalCost,
|
||||
TotalPrice = totalCost,
|
||||
ItemMaterialCost = totalCost,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc,
|
||||
Coats = [],
|
||||
PrepServices = []
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
|
||||
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
|
||||
/// new (or existing) inventory record.
|
||||
///
|
||||
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
|
||||
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
|
||||
/// only reflects powders the shop is actually going to process.
|
||||
///
|
||||
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
|
||||
/// same InventoryItemId — no duplicate records are created.
|
||||
///
|
||||
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
|
||||
/// on an already-approved quote (e.g. retry after a transient error) is safe.
|
||||
/// </summary>
|
||||
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
|
||||
{
|
||||
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
|
||||
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
|
||||
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
|
||||
false,
|
||||
qi => qi.Coats);
|
||||
|
||||
var pendingCoats = quoteItems
|
||||
.SelectMany(qi => qi.Coats)
|
||||
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
|
||||
.ToList();
|
||||
|
||||
if (pendingCoats.Count == 0) return;
|
||||
|
||||
// Group by catalog item ID so each unique powder generates exactly one inventory record.
|
||||
var groups = pendingCoats
|
||||
.GroupBy(c => c.PowderCatalogItemId!.Value)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
|
||||
if (newInventoryId == null) continue;
|
||||
|
||||
// Link every coat in this group to the single newly-created inventory record.
|
||||
foreach (var coat in group)
|
||||
{
|
||||
coat.InventoryItemId = newInventoryId;
|
||||
coat.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
||||
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
|
||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
||||
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
|
||||
/// and the Company Settings live preview (so the UI always shows the same rate
|
||||
/// the AI will use — single formula path, no client-side duplication).
|
||||
///
|
||||
/// Formula:
|
||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
||||
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
|
||||
/// determines throughput and CFM draw. CFM is not used in the rate formula.
|
||||
///
|
||||
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
|
||||
/// All multipliers are relative to that baseline.
|
||||
/// Sources:
|
||||
/// Pressure pot rates — averaged from two industry standard abrasive blast
|
||||
/// cleaning reference tables.
|
||||
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
|
||||
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
|
||||
/// </summary>
|
||||
public static class ShopCapabilityCalculator
|
||||
{
|
||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
||||
// ── Public entry points ────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr.
|
||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
||||
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
|
||||
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (costs.CompressorCfm <= 0)
|
||||
return 0m;
|
||||
|
||||
var baseRate = BaseByCfm(costs.CompressorCfm);
|
||||
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
|
||||
var setup = SetupMultiplier(costs.BlastSetupType);
|
||||
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
|
||||
|
||||
return Math.Round(baseRate * nozzle * setup * substrate, 1);
|
||||
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
||||
/// otherwise derives from the setup's equipment specs.
|
||||
/// Returns the effective blast rate in sqft/hr for a named blast setup.
|
||||
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||
{
|
||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||
|
||||
if (setup.CompressorCfm <= 0)
|
||||
return 0m;
|
||||
|
||||
var baseRate = BaseByCfm(setup.CompressorCfm);
|
||||
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
|
||||
var setupMult = SetupMultiplier(setup.SetupType);
|
||||
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
|
||||
|
||||
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
|
||||
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the effective coating application rate in sqft/hr.
|
||||
/// If override is set, returns it directly.
|
||||
/// Otherwise derives a sensible default from gun type.
|
||||
/// Override bypasses the formula when set.
|
||||
/// </summary>
|
||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||
{
|
||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||
return costs.CoatingRateSqFtPerHourOverride.Value;
|
||||
|
||||
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
|
||||
// Without more equipment data (voltage, gun model) we use a single reasonable default.
|
||||
return costs.CoatingGunType switch
|
||||
{
|
||||
CoatingGunType.Corona => 40m,
|
||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
||||
CoatingGunType.Tribo => 35m,
|
||||
CoatingGunType.Both => 40m,
|
||||
_ => 40m
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns default equipment field values for a given capability tier.
|
||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
||||
/// starting values even if they never visit the Quoting Calibration tab.
|
||||
/// Returns default equipment field values for a given capability tier, applied
|
||||
/// during Setup Wizard tier selection so new shops get reasonable starting values.
|
||||
/// CFM defaults reflect typical compressor sizes for each tier; they appear in the
|
||||
/// UI for reference but are not used in the rate formula.
|
||||
/// </summary>
|
||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||
{
|
||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
|
||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
|
||||
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
|
||||
};
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
// ── Core formula (single path for all callers) ─────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
||||
/// Calibrated so that real-world examples produce expected results:
|
||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
||||
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
|
||||
/// setup type routes to the appropriate reference table; substrate adjusts for
|
||||
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
|
||||
/// not an independent variable in throughput.
|
||||
/// </summary>
|
||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
||||
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
|
||||
{
|
||||
< 10 => 5m,
|
||||
< 20 => 9m,
|
||||
< 40 => 15m,
|
||||
< 80 => 25m,
|
||||
< 120 => 35m,
|
||||
_ => 45m
|
||||
var baseRate = setupType switch
|
||||
{
|
||||
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
|
||||
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
|
||||
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
|
||||
BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
|
||||
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
|
||||
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
|
||||
_ => 0m
|
||||
};
|
||||
|
||||
return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
|
||||
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
|
||||
/// #1 (1/16"): 20-35 sqft/hr avg → 20
|
||||
/// #2 (1/8"): 40-60 sqft/hr avg → 40
|
||||
/// #3 (3/16"): 60-85 sqft/hr avg → 75
|
||||
/// #4 (1/4"): 90-110 sqft/hr avg → 115
|
||||
/// #5 (5/16"): 130-160 sqft/hr avg → 175
|
||||
/// #6 (3/8"): 180-230 sqft/hr avg → 245
|
||||
/// #7 (7/16"): 240-300 sqft/hr avg → 325
|
||||
/// #8 (1/2"): 320-400 sqft/hr avg → 430
|
||||
/// </summary>
|
||||
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
|
||||
{
|
||||
1 => 20m,
|
||||
2 => 40m,
|
||||
3 => 75m,
|
||||
4 => 115m,
|
||||
5 => 175m,
|
||||
6 => 245m,
|
||||
7 => 325m,
|
||||
8 => 430m,
|
||||
_ => 100m
|
||||
};
|
||||
|
||||
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
|
||||
/// <summary>
|
||||
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
|
||||
/// Source: industry reference table for siphon cabinet production rates.
|
||||
/// #1 (1/16"): 10-25 sqft/hr → 18
|
||||
/// #2 (1/8"): 25-50 sqft/hr → 38
|
||||
/// #3 (3/16"): 50-100 sqft/hr → 75
|
||||
/// #4 (1/4"): 100-150 sqft/hr → 125
|
||||
/// #5 (5/16"): 150-225 sqft/hr → 188
|
||||
/// #6 (3/8"): 225-300 sqft/hr → 263
|
||||
/// #7 (7/16"): 300-375 sqft/hr → 338
|
||||
/// #8 (1/2"): 375-450 sqft/hr → 413
|
||||
/// </summary>
|
||||
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
|
||||
{
|
||||
2 => 0.35m,
|
||||
3 => 0.55m,
|
||||
4 => 0.75m,
|
||||
5 => 1.00m,
|
||||
6 => 1.30m,
|
||||
7 => 1.65m,
|
||||
8 => 2.00m,
|
||||
_ => 1.00m
|
||||
};
|
||||
|
||||
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
|
||||
{
|
||||
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
|
||||
BlastSetupType.SiphonPot => 0.70m,
|
||||
BlastSetupType.PressurePot => 1.00m, // baseline
|
||||
BlastSetupType.WetBlasting => 0.60m,
|
||||
_ => 1.00m
|
||||
1 => 18m,
|
||||
2 => 38m,
|
||||
3 => 75m,
|
||||
4 => 125m,
|
||||
5 => 188m,
|
||||
6 => 263m,
|
||||
7 => 338m,
|
||||
8 => 413m,
|
||||
_ => 80m
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
|
||||
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
|
||||
/// </summary>
|
||||
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
||||
{
|
||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
||||
BlastSubstrateType.PowderCoat => 1.25m,
|
||||
BlastSubstrateType.Paint => 1.00m,
|
||||
BlastSubstrateType.Mixed => 0.90m,
|
||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
||||
BlastSubstrateType.RustAndScale => 0.70m,
|
||||
_ => 0.90m
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
|
||||
// Passkey enrollment prompt
|
||||
public bool PasskeyPromptDismissed { get; set; } = false;
|
||||
|
||||
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
|
||||
public string? KioskPin { get; set; }
|
||||
|
||||
// Ban
|
||||
public bool IsBanned { get; set; } = false;
|
||||
public DateTime? BannedAt { get; set; }
|
||||
|
||||
@@ -133,6 +133,15 @@ public class Company : BaseEntity
|
||||
/// </summary>
|
||||
public string? KioskActivationToken { get; set; }
|
||||
|
||||
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
|
||||
public bool TimeclockEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
|
||||
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
|
||||
|
||||
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
|
||||
public int? TimeclockAutoClockOutHours { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A per-company reusable pricing formula template. Users define named input fields and an
|
||||
/// NCalc expression that produces either a fixed dollar amount (FixedRate) or a surface area
|
||||
/// in square feet (SurfaceAreaSqFt) that feeds the standard coatings pricing path.
|
||||
/// </summary>
|
||||
public class CustomItemTemplate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — controls which pricing path is used after evaluation.</summary>
|
||||
public string OutputMode { get; set; } = "FixedRate";
|
||||
|
||||
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||
public string FieldsJson { get; set; } = "[]";
|
||||
|
||||
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
|
||||
public string Formula { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Default rate value populated into the quote wizard; user can override per quote.</summary>
|
||||
public decimal? DefaultRate { get; set; }
|
||||
|
||||
/// <summary>Display label for the rate field, e.g. "$/sq in" or "$/lb".</summary>
|
||||
public string? RateLabel { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
public int DisplayOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional reference diagram (shop drawing, sketch) stored in blob storage.
|
||||
/// Shown in the template editor and quote wizard so users know which measurements to take.
|
||||
/// Path format: {companyId}/{templateId}/diagram.{ext}
|
||||
/// </summary>
|
||||
public string? DiagramImagePath { get; set; }
|
||||
|
||||
// ── Community library tracking ─────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Set when this template was imported from the community library.
|
||||
/// Null for originally created templates.
|
||||
/// </summary>
|
||||
public int? SourceFormulaLibraryItemId { get; set; }
|
||||
public virtual FormulaLibraryItem? SourceFormulaLibraryItem { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True once the user edits an imported template. Only modified imports (and original
|
||||
/// creations) are eligible to be shared back to the community library.
|
||||
/// </summary>
|
||||
public bool IsModifiedFromSource { get; set; }
|
||||
}
|
||||
@@ -41,6 +41,17 @@ public class Customer : BaseEntity
|
||||
public bool IsActive { get; set; } = true;
|
||||
public DateTime? LastContactDate { get; set; }
|
||||
|
||||
// CRM fields
|
||||
/// <summary>How the customer found the shop (Walk-In, Google Search, Customer Referral, etc.).</summary>
|
||||
public string? LeadSource { get; set; }
|
||||
|
||||
// Ship-to / alternate address (separate from billing address above)
|
||||
public string? ShipToAddress { get; set; }
|
||||
public string? ShipToCity { get; set; }
|
||||
public string? ShipToState { get; set; }
|
||||
public string? ShipToZipCode { get; set; }
|
||||
public string? ShipToCountry { get; set; }
|
||||
|
||||
// Notification preferences
|
||||
public bool NotifyByEmail { get; set; } = true;
|
||||
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
||||
@@ -55,4 +66,5 @@ public class Customer : BaseEntity
|
||||
|
||||
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
||||
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
||||
public virtual ICollection<CustomerContact> CustomerContacts { get; set; } = new List<CustomerContact>();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// An additional contact person associated with a customer account.
|
||||
/// Commercial customers frequently have separate billing, operations, and drop-off contacts.
|
||||
/// The primary contact remains on the Customer entity; these are supplementary.
|
||||
/// </summary>
|
||||
public class CustomerContact : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
|
||||
[Required]
|
||||
[StringLength(100)]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[StringLength(100)]
|
||||
public string? LastName { get; set; }
|
||||
|
||||
/// <summary>Job title / role at the company, e.g. "Purchasing Manager".</summary>
|
||||
[StringLength(100)]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Functional role: Billing, Operations, Drop-off, Sales, General, etc.</summary>
|
||||
[StringLength(50)]
|
||||
public string? ContactRole { get; set; }
|
||||
|
||||
[StringLength(200)]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[StringLength(20)]
|
||||
public string? MobilePhone { get; set; }
|
||||
|
||||
[StringLength(500)]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer? Customer { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Facility-level clock-in/clock-out record for an employee.
|
||||
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
|
||||
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
|
||||
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
|
||||
/// </summary>
|
||||
public class EmployeeClockEntry : BaseEntity
|
||||
{
|
||||
public string UserId { get; set; } = string.Empty;
|
||||
|
||||
public DateTime ClockInTime { get; set; }
|
||||
|
||||
/// <summary>Null means the employee is currently clocked in.</summary>
|
||||
public DateTime? ClockOutTime { get; set; }
|
||||
|
||||
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
|
||||
public decimal? HoursWorked { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this segment is regular work time, a break, or a lunch period.
|
||||
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
|
||||
/// </summary>
|
||||
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual ApplicationUser User { get; set; } = null!;
|
||||
}
|
||||
@@ -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? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
|
||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
||||
|
||||
// Additional Information
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? SpecialInstructions { get; set; }
|
||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||
public string? Tags { get; set; }
|
||||
|
||||
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
|
||||
public int? AiPredictionId { get; set; }
|
||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||
|
||||
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||
|
||||
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
|
||||
// Relationships
|
||||
public virtual Job Job { get; set; } = null!;
|
||||
public virtual CatalogItem? CatalogItem { get; set; }
|
||||
|
||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
public string? ProjectName { get; set; }
|
||||
public string? Tags { get; set; }
|
||||
|
||||
// Conversion tracking
|
||||
|
||||
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
|
||||
public int? AiPredictionId { get; set; }
|
||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||
|
||||
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||
public bool IsCustomFormulaItem { get; set; }
|
||||
public int? CustomItemTemplateId { get; set; }
|
||||
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||
|
||||
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||
public string? FormulaFieldValuesJson { get; set; }
|
||||
|
||||
// Relationships
|
||||
public virtual Quote Quote { get; set; } = null!;
|
||||
public virtual CatalogItem? CatalogItem { get; set; }
|
||||
|
||||
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
|
||||
|
||||
// Powder selection (same pattern as current QuoteItem)
|
||||
public int? InventoryItemId { get; set; } // In-stock powder
|
||||
/// <summary>
|
||||
/// Platform powder catalog item that this coat was sourced from.
|
||||
/// Persisted so that at quote-approval time the system can create exactly one
|
||||
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
|
||||
/// than creating during quote-save when the job may never be approved.
|
||||
/// </summary>
|
||||
public int? PowderCatalogItemId { get; set; }
|
||||
public string? ColorName { get; set; } // Color name
|
||||
public int? VendorId { get; set; } // Vendor for custom powder
|
||||
public string? ColorCode { get; set; } // RAL code, etc.
|
||||
|
||||
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
|
||||
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
||||
public bool AllowSms { get; set; } = false;
|
||||
|
||||
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
|
||||
public bool AllowCustomFormulas { get; set; } = false;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records an inventory item as a preferred powder for a specific customer.
|
||||
/// Shown on Customer Details for faster quoting of repeat orders.
|
||||
/// </summary>
|
||||
public class CustomerPreferredPowder : BaseEntity
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public int InventoryItemId { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public virtual Customer Customer { get; set; } = null!;
|
||||
public virtual InventoryItem InventoryItem { get; set; } = null!;
|
||||
}
|
||||
|
||||
public class JobStatusHistory : BaseEntity
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an activated shop-floor kiosk tablet for the timeclock.
|
||||
/// One row per device; multiple rows per company are supported so shops can have
|
||||
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
|
||||
/// device-specific cookie and validated on every kiosk request.
|
||||
/// </summary>
|
||||
public class TimeclockKioskDevice : BaseEntity
|
||||
{
|
||||
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC timestamp when a manager activated this device.</summary>
|
||||
public DateTime ActivatedAt { get; set; }
|
||||
|
||||
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
|
||||
public DateTime? LastSeenAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace PowderCoating.Core.Enums;
|
||||
|
||||
/// <summary>
|
||||
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
|
||||
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
|
||||
/// </summary>
|
||||
public enum ClockEntryType
|
||||
{
|
||||
/// <summary>Normal productive work time (default).</summary>
|
||||
Work = 0,
|
||||
|
||||
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
|
||||
Break = 1,
|
||||
|
||||
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
|
||||
Lunch = 2
|
||||
}
|
||||
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
|
||||
IJobPhotoRepository JobPhotos { get; }
|
||||
IRepository<JobNote> JobNotes { get; }
|
||||
IRepository<CustomerNote> CustomerNotes { get; }
|
||||
IRepository<CustomerContact> CustomerContacts { get; }
|
||||
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||
IRepository<PricingTier> PricingTiers { get; }
|
||||
|
||||
@@ -155,6 +157,18 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
// Customer Intake Kiosk
|
||||
IRepository<KioskSession> KioskSessions { get; }
|
||||
|
||||
// Custom Formula Templates
|
||||
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
|
||||
|
||||
// Formula Community Library
|
||||
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
|
||||
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
|
||||
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
|
||||
|
||||
// Employee Timeclock
|
||||
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
|
||||
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
|
||||
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||
|
||||
|
||||
@@ -230,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<JobNote> JobNotes { get; set; }
|
||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||||
/// <summary>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomerContact> CustomerContacts { get; set; }
|
||||
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
|
||||
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
|
||||
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||||
@@ -289,6 +293,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// </summary>
|
||||
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
||||
|
||||
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
|
||||
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
|
||||
|
||||
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
|
||||
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
|
||||
|
||||
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
|
||||
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
|
||||
|
||||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BugReport> BugReports { get; set; }
|
||||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||||
@@ -374,6 +387,17 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||
|
||||
// Custom Formula Templates
|
||||
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
|
||||
|
||||
// Employee Timeclock
|
||||
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
|
||||
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
|
||||
|
||||
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
|
||||
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||
/// No global query filter — SuperAdmin controllers query this directly.
|
||||
@@ -531,6 +555,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
@@ -767,6 +793,32 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
.HasForeignKey(k => k.LinkedJobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Custom Formula Templates — tenant-filtered + soft delete
|
||||
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Employee Timeclock — tenant-filtered + soft delete
|
||||
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
|
||||
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
|
||||
modelBuilder.Entity<EmployeeClockEntry>()
|
||||
.HasOne(c => c.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(c => c.UserId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
// Composite index for "who's clocked in today" and date-range attendance reports
|
||||
modelBuilder.Entity<EmployeeClockEntry>()
|
||||
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
|
||||
|
||||
// Timeclock kiosk devices — one row per activated tablet per company
|
||||
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
|
||||
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||
.HasIndex(d => d.Token).IsUnique();
|
||||
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||
.HasIndex(d => d.CompanyId);
|
||||
|
||||
// Account self-referencing hierarchy
|
||||
modelBuilder.Entity<Account>()
|
||||
.HasOne(a => a.ParentAccount)
|
||||
@@ -1673,6 +1725,23 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||||
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.Customer)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.CustomerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||
.HasOne(p => p.InventoryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.InventoryItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// ===================================================================
|
||||
// END PERFORMANCE OPTIMIZATION INDEXES
|
||||
// ===================================================================
|
||||
@@ -2037,6 +2106,61 @@ modelBuilder.Entity<Job>()
|
||||
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
||||
|
||||
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
|
||||
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasOne(f => f.InspiredBy)
|
||||
.WithMany()
|
||||
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasIndex(f => f.SourceCompanyId)
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryItem>()
|
||||
.HasIndex(f => f.IsPublished)
|
||||
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||
|
||||
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasOne(i => i.FormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.FormulaLibraryItemId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasOne(i => i.ResultingCustomItemTemplate)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryImport>()
|
||||
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||
|
||||
// FormulaLibraryRating — platform-level; one vote per company per formula
|
||||
modelBuilder.Entity<FormulaLibraryRating>()
|
||||
.HasOne(r => r.FormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.FormulaLibraryItemId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
modelBuilder.Entity<FormulaLibraryRating>()
|
||||
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||
|
||||
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
|
||||
modelBuilder.Entity<CustomItemTemplate>()
|
||||
.HasOne(t => t.SourceFormulaLibraryItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
|
||||
.IsRequired(false)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -2141,7 +2265,9 @@ modelBuilder.Entity<Job>()
|
||||
|
||||
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;
|
||||
|
||||
// Auto-set CompanyId for new entities (if not already set)
|
||||
|
||||
Generated
+10780
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCustomItemTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "QuoteItems",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "QuoteItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "QuoteItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "JobItems",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "JobItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "JobItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CustomItemTemplates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
DisplayOrder = table.Column<int>(type: "int", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CustomItemTemplates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||
table: "QuoteItems",
|
||||
column: "CustomItemTemplateId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobItems_CustomItemTemplateId",
|
||||
table: "JobItems",
|
||||
column: "CustomItemTemplateId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "JobItems",
|
||||
column: "CustomItemTemplateId",
|
||||
principalTable: "CustomItemTemplates",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "QuoteItems",
|
||||
column: "CustomItemTemplateId",
|
||||
principalTable: "CustomItemTemplates",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "CustomItemTemplates");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_JobItems_CustomItemTemplateId",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "QuoteItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CustomItemTemplateId",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FormulaFieldValuesJson",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCustomFormulaItem",
|
||||
table: "JobItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||
}
|
||||
}
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,6 +556,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<string>("KioskPin")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("LaborCostPerHour")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -1923,6 +1926,15 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("TimeZone")
|
||||
.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")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -2650,6 +2662,88 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.ToTable("CreditMemoApplications");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<int>("CompanyId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal?>("DefaultRate")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("DeletedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("DiagramImagePath")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("DisplayOrder")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("FieldsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Formula")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsModifiedFromSource")
|
||||
.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<int?>("SourceFormulaLibraryItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("UpdatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceFormulaLibraryItemId");
|
||||
|
||||
b.ToTable("CustomItemTemplates");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2724,6 +2818,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<DateTime?>("LastContactDate")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("LeadSource")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("MobilePhone")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2742,6 +2839,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int?>("PricingTierId")
|
||||
.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")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -2800,6 +2912,81 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2850,6 +3037,58 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -2960,6 +3199,66 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3291,6 +3590,183 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -3938,6 +4414,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PreparedById")
|
||||
.HasColumnType("nvarchar(450)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("PublicViewToken")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -4229,6 +4708,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("PricingBreakdownJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("QuoteId")
|
||||
.HasColumnType("int");
|
||||
|
||||
@@ -4473,6 +4955,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("CustomItemTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -4489,12 +4974,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Finish")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("FormulaFieldValuesJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IncludePrepCost")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsAiItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsCustomFormulaItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -4558,6 +5049,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("CatalogItemId");
|
||||
|
||||
b.HasIndex("CustomItemTemplateId");
|
||||
|
||||
b.HasIndex("JobId")
|
||||
.HasDatabaseName("IX_JobItems_JobId");
|
||||
|
||||
@@ -6711,7 +7204,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 1,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129),
|
||||
Description = "Standard pricing for regular customers",
|
||||
DiscountPercent = 0m,
|
||||
IsActive = true,
|
||||
@@ -6722,7 +7215,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 2,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137),
|
||||
Description = "5% discount for preferred customers",
|
||||
DiscountPercent = 5m,
|
||||
IsActive = true,
|
||||
@@ -6733,7 +7226,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
Id = 3,
|
||||
CompanyId = 0,
|
||||
CreatedAt = new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204),
|
||||
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138),
|
||||
Description = "10% discount for premium customers",
|
||||
DiscountPercent = 10m,
|
||||
IsActive = true,
|
||||
@@ -7043,6 +7536,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<decimal>("ProfitPercent")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
b.Property<string>("ProjectName")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ProspectAddress")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
@@ -7260,6 +7756,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("CustomItemTemplateId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
@@ -7273,12 +7772,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<int>("EstimatedMinutes")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("FormulaFieldValuesJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<bool>("IncludePrepCost")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsAiItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsCustomFormulaItem")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -7348,6 +7853,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.HasIndex("CatalogItemId");
|
||||
|
||||
b.HasIndex("CustomItemTemplateId");
|
||||
|
||||
b.HasIndex("QuoteId")
|
||||
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
||||
|
||||
@@ -7414,6 +7921,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int?>("PowderCatalogItemId")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<decimal?>("PowderCostPerLb")
|
||||
.HasColumnType("decimal(18,2)");
|
||||
|
||||
@@ -8076,6 +8586,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
b.Property<bool>("AllowAiPhotoQuotes")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("AllowCustomFormulas")
|
||||
.HasColumnType("bit");
|
||||
|
||||
b.Property<bool>("AllowOnlinePayments")
|
||||
.HasColumnType("bit");
|
||||
|
||||
@@ -8252,6 +8765,61 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
@@ -9034,6 +9602,16 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||
@@ -9050,6 +9628,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||
@@ -9061,6 +9650,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
||||
@@ -9097,6 +9705,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||
@@ -9179,6 +9798,46 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||
@@ -9512,6 +10171,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.HasForeignKey("CatalogItemId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomItemTemplateId");
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
|
||||
.WithMany("JobItems")
|
||||
.HasForeignKey("JobId")
|
||||
@@ -9522,6 +10185,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("CatalogItem");
|
||||
|
||||
b.Navigation("CustomItemTemplate");
|
||||
|
||||
b.Navigation("Job");
|
||||
});
|
||||
|
||||
@@ -10131,6 +10796,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
.WithMany()
|
||||
.HasForeignKey("CatalogItemId");
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomItemTemplateId");
|
||||
|
||||
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
|
||||
.WithMany("QuoteItems")
|
||||
.HasForeignKey("QuoteId")
|
||||
@@ -10141,6 +10810,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
b.Navigation("CatalogItem");
|
||||
|
||||
b.Navigation("CustomItemTemplate");
|
||||
|
||||
b.Navigation("Quote");
|
||||
});
|
||||
|
||||
@@ -10493,6 +11164,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
|
||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||
{
|
||||
b.Navigation("CustomerContacts");
|
||||
|
||||
b.Navigation("CustomerNotes");
|
||||
|
||||
b.Navigation("Invoices");
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
||||
|
||||
@@ -70,6 +70,8 @@ public class UnitOfWork : IUnitOfWork
|
||||
private IJobPhotoRepository? _jobPhotos;
|
||||
private IRepository<JobNote>? _jobNotes;
|
||||
private IRepository<CustomerNote>? _customerNotes;
|
||||
private IRepository<CustomerContact>? _customerContacts;
|
||||
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||
private IRepository<PricingTier>? _pricingTiers;
|
||||
|
||||
@@ -123,6 +125,18 @@ public class UnitOfWork : IUnitOfWork
|
||||
// Customer Intake Kiosk
|
||||
private IRepository<KioskSession>? _kioskSessions;
|
||||
|
||||
// Employee Timeclock
|
||||
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
|
||||
private IRepository<TimeclockKioskDevice>? _timeclockKioskDevices;
|
||||
|
||||
// Custom Formula Templates
|
||||
private IRepository<CustomItemTemplate>? _customItemTemplates;
|
||||
|
||||
// Formula Community Library
|
||||
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
|
||||
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
|
||||
private IPlainRepository<FormulaLibraryRating>? _formulaLibraryRatings;
|
||||
|
||||
// Purchase Orders
|
||||
private IPurchaseOrderRepository? _purchaseOrders;
|
||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||
@@ -309,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>
|
||||
public IRepository<CustomerNote> CustomerNotes =>
|
||||
_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>
|
||||
public IRepository<JobStatusHistory> JobStatusHistory =>
|
||||
@@ -457,6 +476,30 @@ public class UnitOfWork : IUnitOfWork
|
||||
public IRepository<KioskSession> KioskSessions =>
|
||||
_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>
|
||||
public IRepository<CustomItemTemplate> CustomItemTemplates =>
|
||||
_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
|
||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||
public IJobTemplateRepository JobTemplates =>
|
||||
|
||||
@@ -262,6 +262,7 @@ public class CsvImportService : ICsvImportService
|
||||
JobNumber = "JOB-2601-0001",
|
||||
CustomerEmail = "customer@example.com",
|
||||
CustomerName = "Acme Corp (used if email is blank or not found)",
|
||||
Description = "Sample job description",
|
||||
Status = "Pending",
|
||||
Priority = "Normal",
|
||||
ScheduledDate = DateTime.Today.AddDays(7),
|
||||
@@ -269,7 +270,7 @@ public class CsvImportService : ICsvImportService
|
||||
FinalPrice = 750.00m,
|
||||
CustomerPO = "PO-12345",
|
||||
SpecialInstructions = "Handle with care",
|
||||
Notes = "Sample job"
|
||||
Notes = "Internal notes"
|
||||
});
|
||||
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.
|
||||
/// 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.
|
||||
/// Duplicate detection runs against both existing DB records (by email) and within the import file
|
||||
/// itself, catching cases where the same email appears twice in one upload.
|
||||
/// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -418,15 +426,53 @@ public class CsvImportService : ICsvImportService
|
||||
|
||||
// Get all existing customers for duplicate detection
|
||||
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
|
||||
|
||||
// Tier 1 lookup: email → existing customer
|
||||
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||
.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
|
||||
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
||||
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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)
|
||||
{
|
||||
rowNumber++;
|
||||
@@ -434,7 +480,12 @@ public class CsvImportService : ICsvImportService
|
||||
{
|
||||
// Strip any literal quote characters that QB/Excel may wrap around field values
|
||||
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 lastName = StripQuotes(record.ContactLastName)?.Trim();
|
||||
|
||||
@@ -451,20 +502,68 @@ public class CsvImportService : ICsvImportService
|
||||
cleanCompanyName = derivedName;
|
||||
}
|
||||
|
||||
// Check for duplicate email in existing data
|
||||
if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower()))
|
||||
{
|
||||
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
// Canonical display name used as part of composite keys in Tiers 2 and 3
|
||||
var displayName = string.IsNullOrWhiteSpace(cleanCompanyName)
|
||||
? $"{firstName} {lastName}".Trim()
|
||||
: cleanCompanyName;
|
||||
|
||||
// Check for duplicate email within the import batch
|
||||
if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
// --- Tier 1: email dedup ---
|
||||
// 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.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
if (existingEmails.ContainsKey(cleanEmail.ToLower()))
|
||||
{
|
||||
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
|
||||
@@ -506,12 +605,41 @@ public class CsvImportService : ICsvImportService
|
||||
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
|
||||
IsTaxExempt = record.TaxExempt ?? false,
|
||||
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,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -1162,6 +1290,7 @@ public class CsvImportService : ICsvImportService
|
||||
Total = record.Total,
|
||||
Notes = record.Notes?.Trim(),
|
||||
Terms = record.TermsAndConditions?.Trim(),
|
||||
ProjectName = record.ProjectName?.Trim(),
|
||||
IsCommercial = customerId.HasValue,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
@@ -1268,24 +1397,22 @@ public class CsvImportService : ICsvImportService
|
||||
MissingFieldFound = null
|
||||
});
|
||||
|
||||
var records = csv.GetRecords<JobImportDto>().ToList();
|
||||
result.TotalRows = records.Count;
|
||||
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
|
||||
// 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 existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||
.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 customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||
.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);
|
||||
foreach (var c in customers)
|
||||
{
|
||||
@@ -1296,19 +1423,42 @@ public class CsvImportService : ICsvImportService
|
||||
customerByName.TryAdd(name, c);
|
||||
}
|
||||
|
||||
// Get job statuses for lookup
|
||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Get job priorities for lookup
|
||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
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
|
||||
{
|
||||
// Validate required fields
|
||||
@@ -1414,7 +1564,10 @@ public class CsvImportService : ICsvImportService
|
||||
CustomerPO = record.CustomerPO?.Trim(),
|
||||
SpecialInstructions = record.SpecialInstructions?.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,
|
||||
UpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
@@ -2813,6 +2966,23 @@ public class CsvImportService : ICsvImportService
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
@@ -2984,9 +3154,10 @@ public class CsvImportService : ICsvImportService
|
||||
existing.DiscountAmount = record.DiscountAmount;
|
||||
existing.Total = record.Total;
|
||||
existing.AmountPaid = record.AmountPaid;
|
||||
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
|
||||
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
|
||||
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
|
||||
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
|
||||
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
|
||||
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
|
||||
existing.ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
result.SuccessCount++;
|
||||
}
|
||||
@@ -3008,9 +3179,10 @@ public class CsvImportService : ICsvImportService
|
||||
DiscountAmount = record.DiscountAmount,
|
||||
Total = record.Total,
|
||||
AmountPaid = record.AmountPaid,
|
||||
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
|
||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
|
||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.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.CompleteAsync();
|
||||
@@ -3340,4 +3512,23 @@ public class CsvImportService : ICsvImportService
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using NCalc2;
|
||||
using Anthropic.SDK;
|
||||
using Anthropic.SDK.Messaging;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Generates NCalc pricing formula templates from natural-language descriptions using
|
||||
/// Claude Sonnet. Accepts an optional diagram image so the model can see the physical
|
||||
/// shape being estimated. The model returns a structured JSON object containing the
|
||||
/// field list, NCalc expression, output mode, and verification inputs; the service
|
||||
/// parses and returns it as a <see cref="GenerateFormulaFromAiResponse"/>.
|
||||
/// </summary>
|
||||
public class CustomFormulaAiService : ICustomFormulaAiService
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<CustomFormulaAiService> _logger;
|
||||
|
||||
private const string SystemPrompt = @"You are an expert pricing formula engineer for a powder coating business.
|
||||
Your job is to generate NCalc expressions that calculate either a fixed price or a surface area
|
||||
from user-supplied field values.
|
||||
|
||||
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',
|
||||
'Tubular frame') and you must produce a pricing formula template.
|
||||
|
||||
Respond ONLY with a valid JSON object matching this exact schema — no markdown, no explanation:
|
||||
|
||||
{
|
||||
""name"": ""string — short template name (e.g. 'Roof Curb', 'Electrical Enclosure')"",
|
||||
""outputMode"": ""FixedRate"" | ""SurfaceAreaSqFt"",
|
||||
""fields"": [
|
||||
{
|
||||
""name"": ""snake_case_variable_name"",
|
||||
""label"": ""Human-readable label"",
|
||||
""unit"": ""in / ft / mm / cm / qty / lbs — or empty string"",
|
||||
""defaultValue"": number
|
||||
}
|
||||
],
|
||||
""formula"": ""NCalc expression using field name variables and optionally 'rate'"",
|
||||
""defaultRate"": number or null,
|
||||
""rateLabel"": ""string label for the rate field, e.g. '$/sq ft' — null if no rate"",
|
||||
""reasoning"": ""1-2 sentences explaining how the formula was derived"",
|
||||
""verificationInputs"": { ""variable_name"": number },
|
||||
""verificationResult"": number
|
||||
}
|
||||
|
||||
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:
|
||||
- Use FixedRate when the formula directly calculates a dollar amount (e.g. surface area × rate per sqft)
|
||||
- Use SurfaceAreaSqFt when the formula calculates square footage and the standard pricing engine handles the rest
|
||||
- Always include a 'rate' variable when outputMode is FixedRate and the price scales with dimensions, 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)
|
||||
- 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
|
||||
- Surface area formulas for box shapes: 2*(L*W + L*H + W*H) where L/W/H are in the same unit; convert to sqft if needed
|
||||
- For inch inputs convert to sqft: divide by 144 (sqin→sqft) or use /12 per side before multiplying
|
||||
";
|
||||
|
||||
public CustomFormulaAiService(IConfiguration config, ILogger<CustomFormulaAiService> logger)
|
||||
{
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GenerateFormulaFromAiResponse> GenerateFormulaAsync(
|
||||
GenerateFormulaFromAiRequest request,
|
||||
byte[]? imageBytes = null,
|
||||
string? imageContentType = null)
|
||||
{
|
||||
var apiKey = _config["AI:Anthropic:ApiKey"];
|
||||
if (string.IsNullOrWhiteSpace(apiKey) || apiKey.StartsWith("your-"))
|
||||
{
|
||||
return new GenerateFormulaFromAiResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = "Anthropic API key is not configured. Add AI:Anthropic:ApiKey to appsettings.json."
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var client = new AnthropicClient(apiKey);
|
||||
|
||||
var userContent = new List<ContentBase>();
|
||||
|
||||
if (imageBytes is { Length: > 0 } && !string.IsNullOrWhiteSpace(imageContentType))
|
||||
{
|
||||
userContent.Add(new ImageContent
|
||||
{
|
||||
Source = new ImageSource
|
||||
{
|
||||
MediaType = imageContentType,
|
||||
Data = Convert.ToBase64String(imageBytes)
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
userContent.Add(new TextContent { Text = request.Description });
|
||||
|
||||
var messages = new List<Message>
|
||||
{
|
||||
new() { Role = RoleType.User, Content = userContent }
|
||||
};
|
||||
|
||||
var response = await client.Messages.GetClaudeMessageAsync(new MessageParameters
|
||||
{
|
||||
Model = "claude-sonnet-4-6",
|
||||
MaxTokens = 1024,
|
||||
SystemMessage = SystemPrompt,
|
||||
Messages = messages
|
||||
});
|
||||
|
||||
var rawJson = response.Message.ToString().Trim();
|
||||
|
||||
// Strip markdown code fences if the model adds them
|
||||
if (rawJson.StartsWith("```"))
|
||||
{
|
||||
var start = rawJson.IndexOf('\n') + 1;
|
||||
var end = rawJson.LastIndexOf("```");
|
||||
if (end > start) rawJson = rawJson[start..end].Trim();
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(rawJson);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var fieldsJson = root.TryGetProperty("fields", out var fieldsEl)
|
||||
? fieldsEl.GetRawText()
|
||||
: "[]";
|
||||
|
||||
decimal? defaultRate = null;
|
||||
if (root.TryGetProperty("defaultRate", out var rateEl) && rateEl.ValueKind == JsonValueKind.Number)
|
||||
defaultRate = rateEl.GetDecimal();
|
||||
|
||||
decimal? verificationResult = null;
|
||||
if (root.TryGetProperty("verificationResult", out var vrEl) && vrEl.ValueKind == JsonValueKind.Number)
|
||||
verificationResult = vrEl.GetDecimal();
|
||||
|
||||
string? verificationInputs = null;
|
||||
if (root.TryGetProperty("verificationInputs", out var viEl))
|
||||
verificationInputs = viEl.GetRawText();
|
||||
|
||||
return new GenerateFormulaFromAiResponse
|
||||
{
|
||||
Success = true,
|
||||
Name = root.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
|
||||
OutputMode = root.TryGetProperty("outputMode", out var omEl) ? omEl.GetString() : "FixedRate",
|
||||
FieldsJson = fieldsJson,
|
||||
Formula = root.TryGetProperty("formula", out var fEl) ? fEl.GetString() : null,
|
||||
DefaultRate = defaultRate,
|
||||
RateLabel = root.TryGetProperty("rateLabel", out var rlEl) ? rlEl.GetString() : null,
|
||||
Reasoning = root.TryGetProperty("reasoning", out var reEl) ? reEl.GetString() : null,
|
||||
VerificationResult = verificationResult,
|
||||
VerificationInputs = verificationInputs
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "CustomFormulaAiService.GenerateFormulaAsync failed");
|
||||
return new GenerateFormulaFromAiResponse { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
// 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 />
|
||||
public EvaluateFormulaResponse EvaluateFormula(EvaluateFormulaRequest request)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Formula))
|
||||
return new EvaluateFormulaResponse { Success = false, Error = "Formula is required." };
|
||||
|
||||
try
|
||||
{
|
||||
var variables = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
||||
request.VariablesJson ?? "{}") ?? new();
|
||||
|
||||
var expr = new Expression(NormalizeFormula(request.Formula));
|
||||
foreach (var kv in variables)
|
||||
{
|
||||
expr.Parameters[kv.Key] = kv.Value.ValueKind == JsonValueKind.Number
|
||||
? (object)kv.Value.GetDouble()
|
||||
: (object)(kv.Value.GetString() ?? "");
|
||||
}
|
||||
|
||||
var result = expr.Evaluate();
|
||||
var decResult = Convert.ToDecimal(result);
|
||||
return new EvaluateFormulaResponse { Success = true, Result = Math.Round(decResult, 4) };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new EvaluateFormulaResponse { Success = false, Error = ex.Message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,8 @@ public class EmailService : IEmailService
|
||||
if (!_hostEnvironment.IsProduction())
|
||||
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
||||
|
||||
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
|
||||
|
||||
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
||||
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
|
||||
|
||||
@@ -104,6 +106,8 @@ public class EmailService : IEmailService
|
||||
if (!_hostEnvironment.IsProduction())
|
||||
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
|
||||
|
||||
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
|
||||
|
||||
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
|
||||
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>
|
||||
/// Sends the built SendGrid message and interprets the HTTP response. Extracted so both
|
||||
/// send methods share identical dispatch and logging logic.
|
||||
|
||||
@@ -0,0 +1,382 @@
|
||||
using AutoMapper;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using PowderCoating.Application.DTOs.Company;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the community formula library: sharing a company template to the platform-wide
|
||||
/// library, removing it, browsing published entries, and importing an entry as a local copy.
|
||||
/// </summary>
|
||||
public class FormulaLibraryService : IFormulaLibraryService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IMapper _mapper;
|
||||
private readonly ILogger<FormulaLibraryService> _logger;
|
||||
|
||||
public FormulaLibraryService(IUnitOfWork unitOfWork, IMapper mapper, ILogger<FormulaLibraryService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_mapper = mapper;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IEnumerable<FormulaLibraryCardDto>> BrowseAsync(
|
||||
int companyId, string? search, string? outputMode, string? industryHint)
|
||||
{
|
||||
var items = await _unitOfWork.FormulaLibrary.FindAsync(i => i.IsPublished);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var lower = search.ToLowerInvariant();
|
||||
items = items.Where(i =>
|
||||
i.Name.ToLowerInvariant().Contains(lower) ||
|
||||
(i.Description != null && i.Description.ToLowerInvariant().Contains(lower)) ||
|
||||
(i.Tags != null && i.Tags.ToLowerInvariant().Contains(lower)) ||
|
||||
i.SourceCompanyName.ToLowerInvariant().Contains(lower));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(outputMode))
|
||||
items = items.Where(i => i.OutputMode == outputMode);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(industryHint))
|
||||
items = items.Where(i => i.IndustryHint != null &&
|
||||
i.IndustryHint.ToLowerInvariant().Contains(industryHint.ToLowerInvariant()));
|
||||
|
||||
// Load InspiredBy for attribution line
|
||||
var itemList = items.ToList();
|
||||
var inspiredByIds = itemList
|
||||
.Where(i => i.InspiredByFormulaLibraryItemId.HasValue)
|
||||
.Select(i => i.InspiredByFormulaLibraryItemId!.Value)
|
||||
.Distinct()
|
||||
.ToHashSet();
|
||||
|
||||
Dictionary<int, FormulaLibraryItem> inspirations = new();
|
||||
foreach (var id in inspiredByIds)
|
||||
{
|
||||
var parent = await _unitOfWork.FormulaLibrary.GetByIdAsync(id);
|
||||
if (parent != null) inspirations[id] = parent;
|
||||
}
|
||||
|
||||
// Attach navigation properties manually (PlainRepository doesn't eager-load)
|
||||
foreach (var item in itemList)
|
||||
{
|
||||
if (item.InspiredByFormulaLibraryItemId.HasValue &&
|
||||
inspirations.TryGetValue(item.InspiredByFormulaLibraryItemId.Value, out var parent))
|
||||
item.InspiredBy = parent;
|
||||
}
|
||||
|
||||
// Determine which entries this company has already imported
|
||||
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
imp => imp.CompanyId == companyId && !imp.IsDeleted);
|
||||
var importedIds = imports.Select(imp => imp.FormulaLibraryItemId).ToHashSet();
|
||||
|
||||
// Load all ratings in one query for this page of items
|
||||
var allItemIds = itemList.Select(i => i.Id).ToHashSet();
|
||||
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
|
||||
r => allItemIds.Contains(r.FormulaLibraryItemId));
|
||||
|
||||
// Group counts and find the current company's vote per item
|
||||
var ratingsByItem = allRatings
|
||||
.GroupBy(r => r.FormulaLibraryItemId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var dtos = _mapper.Map<List<FormulaLibraryCardDto>>(itemList);
|
||||
for (int i = 0; i < dtos.Count; i++)
|
||||
{
|
||||
dtos[i].AlreadyImported = importedIds.Contains(dtos[i].Id);
|
||||
dtos[i].IsOwnFormula = itemList[i].SourceCompanyId == companyId;
|
||||
|
||||
if (ratingsByItem.TryGetValue(dtos[i].Id, out var ratings))
|
||||
{
|
||||
dtos[i].ThumbsUp = ratings.Count(r => r.IsPositive);
|
||||
dtos[i].ThumbsDown = ratings.Count(r => !r.IsPositive);
|
||||
var myRating = ratings.FirstOrDefault(r => r.CompanyId == companyId);
|
||||
dtos[i].MyVote = myRating?.IsPositive;
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: thumbs-up score descending, then import count, then name
|
||||
return dtos
|
||||
.OrderByDescending(d => d.ThumbsUp - d.ThumbsDown)
|
||||
.ThenByDescending(d => d.ImportCount)
|
||||
.ThenBy(d => d.Name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FormulaLibraryDetailDto?> GetDetailAsync(int libraryItemId, int companyId)
|
||||
{
|
||||
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (item == null || !item.IsPublished) return null;
|
||||
|
||||
if (item.InspiredByFormulaLibraryItemId.HasValue)
|
||||
item.InspiredBy = await _unitOfWork.FormulaLibrary.GetByIdAsync(
|
||||
item.InspiredByFormulaLibraryItemId.Value);
|
||||
|
||||
var dto = _mapper.Map<FormulaLibraryDetailDto>(item);
|
||||
|
||||
var imp = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
|
||||
dto.AlreadyImported = imp.Any();
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ShareAsync(int companyId, string userId, ShareFormulaRequest request)
|
||||
{
|
||||
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(request.CustomItemTemplateId);
|
||||
if (template == null || template.CompanyId != companyId)
|
||||
throw new InvalidOperationException("Template not found.");
|
||||
|
||||
if (!CanShare(template))
|
||||
throw new InvalidOperationException("This template is not eligible to be shared.");
|
||||
|
||||
// Determine "Inspired by" — if this was imported from the library
|
||||
int? inspiredById = null;
|
||||
if (template.SourceFormulaLibraryItemId.HasValue && template.IsModifiedFromSource)
|
||||
inspiredById = template.SourceFormulaLibraryItemId;
|
||||
|
||||
// Get company name for attribution
|
||||
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||
var companyName = company?.CompanyName ?? "Unknown Company";
|
||||
|
||||
// Re-use existing row if one exists (re-share after unshare, or update after edits)
|
||||
var existing = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
||||
f => f.SourceCustomItemTemplateId == template.Id && f.SourceCompanyId == companyId);
|
||||
|
||||
if (existing != null)
|
||||
{
|
||||
CopyFromTemplate(existing, template, companyName, request);
|
||||
existing.InspiredByFormulaLibraryItemId = inspiredById;
|
||||
existing.IsPublished = true;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(existing);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return existing.Id;
|
||||
}
|
||||
|
||||
var libraryItem = new FormulaLibraryItem
|
||||
{
|
||||
SharedByUserId = userId,
|
||||
SharedAt = DateTime.UtcNow,
|
||||
SourceCustomItemTemplateId = template.Id,
|
||||
SourceCompanyId = companyId,
|
||||
SourceCompanyName = companyName,
|
||||
InspiredByFormulaLibraryItemId = inspiredById,
|
||||
IsPublished = true,
|
||||
};
|
||||
CopyFromTemplate(libraryItem, template, companyName, request);
|
||||
|
||||
await _unitOfWork.FormulaLibrary.AddAsync(libraryItem);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return libraryItem.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task UnshareAsync(int libraryItemId, int companyId)
|
||||
{
|
||||
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (item == null || item.SourceCompanyId != companyId) return;
|
||||
|
||||
item.IsPublished = false;
|
||||
item.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(item);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<int> ImportAsync(int libraryItemId, int companyId, string userId)
|
||||
{
|
||||
var libraryItem = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (libraryItem == null || !libraryItem.IsPublished)
|
||||
throw new InvalidOperationException("Library entry not found or no longer published.");
|
||||
|
||||
// Return existing import if already imported
|
||||
var existingImports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
i => i.CompanyId == companyId && i.FormulaLibraryItemId == libraryItemId && !i.IsDeleted);
|
||||
var existingImport = existingImports.FirstOrDefault();
|
||||
if (existingImport != null) return existingImport.ResultingCustomItemTemplateId;
|
||||
|
||||
// Create a local copy as a new CustomItemTemplate
|
||||
var template = new CustomItemTemplate
|
||||
{
|
||||
CompanyId = companyId,
|
||||
Name = libraryItem.Name,
|
||||
Description = libraryItem.Description,
|
||||
OutputMode = libraryItem.OutputMode,
|
||||
FieldsJson = libraryItem.FieldsJson,
|
||||
Formula = libraryItem.Formula,
|
||||
DefaultRate = libraryItem.DefaultRate,
|
||||
RateLabel = libraryItem.RateLabel,
|
||||
Notes = libraryItem.Notes,
|
||||
DiagramImagePath = libraryItem.DiagramImagePath,
|
||||
DisplayOrder = 0,
|
||||
IsActive = true,
|
||||
SourceFormulaLibraryItemId = libraryItemId,
|
||||
IsModifiedFromSource = false,
|
||||
};
|
||||
|
||||
await _unitOfWork.CustomItemTemplates.AddAsync(template);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
var importRecord = new FormulaLibraryImport
|
||||
{
|
||||
CompanyId = companyId,
|
||||
FormulaLibraryItemId = libraryItemId,
|
||||
ImportedByUserId = userId,
|
||||
ImportedAt = DateTime.UtcNow,
|
||||
ResultingCustomItemTemplateId = template.Id,
|
||||
};
|
||||
await _unitOfWork.FormulaLibraryImports.AddAsync(importRecord);
|
||||
|
||||
// Increment import counter
|
||||
libraryItem.ImportCount++;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return template.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FormulaLibraryStatusDto> GetTemplateLibraryStatusAsync(int templateId, int companyId)
|
||||
{
|
||||
var template = await _unitOfWork.CustomItemTemplates.GetByIdAsync(templateId);
|
||||
if (template == null || template.CompanyId != companyId)
|
||||
return new FormulaLibraryStatusDto { CanShare = false };
|
||||
|
||||
var dto = new FormulaLibraryStatusDto { CanShare = CanShare(template) };
|
||||
|
||||
// Populate import attribution
|
||||
if (template.SourceFormulaLibraryItemId.HasValue)
|
||||
{
|
||||
var source = await _unitOfWork.FormulaLibrary.GetByIdAsync(template.SourceFormulaLibraryItemId.Value);
|
||||
if (source != null)
|
||||
{
|
||||
dto.ImportedFromName = source.Name;
|
||||
dto.ImportedFromCompany = source.SourceCompanyName;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this template has an active library entry
|
||||
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
||||
f => f.SourceCustomItemTemplateId == templateId && f.SourceCompanyId == companyId);
|
||||
if (libraryItem != null)
|
||||
{
|
||||
dto.LibraryItemId = libraryItem.Id;
|
||||
dto.IsPublished = libraryItem.IsPublished;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CascadeRemoveDiagramAsync(int sourceCustomItemTemplateId)
|
||||
{
|
||||
// Null out on the library item published from this template
|
||||
var libraryItem = await _unitOfWork.FormulaLibrary.FirstOrDefaultAsync(
|
||||
f => f.SourceCustomItemTemplateId == sourceCustomItemTemplateId);
|
||||
if (libraryItem != null && libraryItem.DiagramImagePath != null)
|
||||
{
|
||||
libraryItem.DiagramImagePath = null;
|
||||
libraryItem.UpdatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibrary.UpdateAsync(libraryItem);
|
||||
|
||||
// Null out on all imported copies
|
||||
var imports = await _unitOfWork.FormulaLibraryImports.FindAsync(
|
||||
i => i.FormulaLibraryItemId == libraryItem.Id && !i.IsDeleted);
|
||||
foreach (var imp in imports)
|
||||
{
|
||||
var copy = await _unitOfWork.CustomItemTemplates.GetByIdAsync(
|
||||
imp.ResultingCustomItemTemplateId);
|
||||
if (copy != null && copy.DiagramImagePath != null)
|
||||
{
|
||||
copy.DiagramImagePath = null;
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<(int ThumbsUp, int ThumbsDown, bool? MyVote)> RateAsync(
|
||||
int libraryItemId, int companyId, bool isPositive)
|
||||
{
|
||||
var item = await _unitOfWork.FormulaLibrary.GetByIdAsync(libraryItemId);
|
||||
if (item == null || !item.IsPublished)
|
||||
throw new InvalidOperationException("Library entry not found.");
|
||||
|
||||
// Companies cannot rate their own formula
|
||||
if (item.SourceCompanyId == companyId)
|
||||
throw new InvalidOperationException("You cannot rate your own formula.");
|
||||
|
||||
var existing = await _unitOfWork.FormulaLibraryRatings.FirstOrDefaultAsync(
|
||||
r => r.FormulaLibraryItemId == libraryItemId && r.CompanyId == companyId);
|
||||
|
||||
bool? myVote;
|
||||
if (existing != null && existing.IsPositive == isPositive)
|
||||
{
|
||||
// Same vote again — toggle off
|
||||
await _unitOfWork.FormulaLibraryRatings.DeleteAsync(existing);
|
||||
myVote = null;
|
||||
}
|
||||
else if (existing != null)
|
||||
{
|
||||
// Opposite vote — flip it
|
||||
existing.IsPositive = isPositive;
|
||||
existing.RatedAt = DateTime.UtcNow;
|
||||
await _unitOfWork.FormulaLibraryRatings.UpdateAsync(existing);
|
||||
myVote = isPositive;
|
||||
}
|
||||
else
|
||||
{
|
||||
// New vote
|
||||
await _unitOfWork.FormulaLibraryRatings.AddAsync(new FormulaLibraryRating
|
||||
{
|
||||
FormulaLibraryItemId = libraryItemId,
|
||||
CompanyId = companyId,
|
||||
IsPositive = isPositive,
|
||||
RatedAt = DateTime.UtcNow,
|
||||
});
|
||||
myVote = isPositive;
|
||||
}
|
||||
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Return fresh counts
|
||||
var allRatings = await _unitOfWork.FormulaLibraryRatings.FindAsync(
|
||||
r => r.FormulaLibraryItemId == libraryItemId);
|
||||
var list = allRatings.ToList();
|
||||
return (list.Count(r => r.IsPositive), list.Count(r => !r.IsPositive), myVote);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A template is shareable if it was created fresh (no source library item) or
|
||||
/// if it was imported but then modified by the company.
|
||||
/// </summary>
|
||||
private static bool CanShare(CustomItemTemplate t) =>
|
||||
t.SourceFormulaLibraryItemId == null || t.IsModifiedFromSource;
|
||||
|
||||
private static void CopyFromTemplate(
|
||||
FormulaLibraryItem dest, CustomItemTemplate src, string companyName, ShareFormulaRequest req)
|
||||
{
|
||||
dest.Name = src.Name;
|
||||
dest.Description = src.Description;
|
||||
dest.OutputMode = src.OutputMode;
|
||||
dest.FieldsJson = src.FieldsJson;
|
||||
dest.Formula = src.Formula;
|
||||
dest.DefaultRate = src.DefaultRate;
|
||||
dest.RateLabel = src.RateLabel;
|
||||
dest.Notes = src.Notes;
|
||||
dest.DiagramImagePath = src.DiagramImagePath;
|
||||
dest.SourceCompanyName = companyName;
|
||||
dest.Tags = req.Tags?.Trim();
|
||||
dest.IndustryHint = req.IndustryHint?.Trim();
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl);
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteSent, values,
|
||||
$"Your Quote {quote.QuoteNumber} from {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl);
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
|
||||
quote.CompanyId, NotificationType.QuoteApproved, values,
|
||||
$"Quote {quote.QuoteNumber} Approved — {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
job.CompanyId, notifType, values, defaultSubject);
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
|
||||
job.CompanyId, NotificationType.JobCompleted, values,
|
||||
$"Job {job.JobNumber} Complete — {companyName}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
|
||||
""";
|
||||
}
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = !string.IsNullOrEmpty(paymentUrl)
|
||||
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
|
||||
: StripHtml(fullHtml);
|
||||
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReceived, values,
|
||||
$"Payment Received — Invoice {invoice.InvoiceNumber}");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
|
||||
invoice.CompanyId, NotificationType.PaymentReminder, values,
|
||||
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error, recipientsLog) = await SendToEmailListAsync(
|
||||
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
|
||||
var (subject, htmlBody) = await GetRenderedEmailAsync(
|
||||
quote.CompanyId, notificationType, values, defaultSubject);
|
||||
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync());
|
||||
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
|
||||
var plainText = StripHtml(fullHtml);
|
||||
|
||||
var (success, error) = await _emailService.SendEmailAsync(
|
||||
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
|
||||
var (custSubject, custHtml) = await GetRenderedEmailAsync(
|
||||
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
|
||||
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl);
|
||||
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
|
||||
var custPlainText = StripHtml(custFullHtml);
|
||||
|
||||
var (custOk, custErr, custLog) = await SendToEmailListAsync(
|
||||
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
|
||||
/// <summary>
|
||||
/// Appends CAN-SPAM required footer as HTML.
|
||||
/// </summary>
|
||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null)
|
||||
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
|
||||
{
|
||||
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
|
||||
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
||||
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
|
||||
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
|
||||
|
||||
if (!hasUnsubscribeUrl && !hasAddress)
|
||||
if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
|
||||
return htmlBody;
|
||||
|
||||
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
|
||||
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
|
||||
|
||||
if (hasReplyTo)
|
||||
{
|
||||
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
|
||||
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
|
||||
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
|
||||
}
|
||||
|
||||
if (hasAddress)
|
||||
{
|
||||
var addressLine = BuildAddressLine(company!);
|
||||
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||
|
||||
return (prefs?.EmailFromAddress, prefs?.EmailFromName);
|
||||
var email = prefs?.EmailFromAddress;
|
||||
var name = prefs?.EmailFromName;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
|
||||
else
|
||||
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
|
||||
|
||||
return (email, name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -44,8 +44,11 @@ public partial class SeedDataService
|
||||
var accounts = new List<Account>
|
||||
{
|
||||
// ── ASSETS ────────────────────────────────────────────────────────
|
||||
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", CompanyId = company.Id, CreatedAt = now },
|
||||
// Opening balances represent accumulated cash before the 12-month seeded history window.
|
||||
// Without them, 12 months of seeded expenses outpace ~3 months of seeded revenue and
|
||||
// the checking account shows a large negative — unrealistic for a demo.
|
||||
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", OpeningBalance = 75_000m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 75_000m, CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", OpeningBalance = 14_500m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 14_500m, CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1100", Name = "Accounts Receivable", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsSystem = true, IsActive = true, Description = "Amounts owed by customers for services", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Powder coating materials in stock", CompanyId = company.Id, CreatedAt = now },
|
||||
new Account { AccountNumber = "1210", Name = "Inventory - Consumables", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Masking, tape, and other consumables", CompanyId = company.Id, CreatedAt = now },
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 8 <see cref="AiItemPrediction"/> demo records and attaches them to the first
|
||||
/// 8 eligible <see cref="QuoteItem"/> records, marking those items as AI-analysed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// "Eligible" means <c>SurfaceAreaSqFt > 0</c>, not a labor item, and not already
|
||||
/// linked to a prediction. This ensures the seeder is safe to run even if a partial seed
|
||||
/// left some items pre-linked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each prediction record captures a realistic AI analysis: predicted surface area,
|
||||
/// estimated minutes, complexity tier, unit price, confidence level, reasoning text, and
|
||||
/// comma-separated AI tags. Three of the eight items have <c>UserOverrodeEstimate = true</c>
|
||||
/// to demonstrate the override-tracking feature on the AI Accuracy report.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Items are updated in-place with <c>IsAiItem = true</c> and the FK
|
||||
/// <c>AiPredictionId</c> pointing to the new prediction. <c>SaveChangesAsync</c> is called
|
||||
/// per item so any single FK conflict (unlikely in a fresh seed) does not abort the others.
|
||||
/// </para>
|
||||
/// Idempotency: returns 0 immediately if any AiItemPrediction records already exist for
|
||||
/// the company.
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed predictions for.</param>
|
||||
/// <returns>Number of prediction records inserted, or 0 if already seeded.</returns>
|
||||
private async Task<int> SeedAiPredictionsAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<AiItemPrediction>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
|
||||
|
||||
if (existingCount > 0)
|
||||
return 0;
|
||||
|
||||
// Grab the first 8 eligible quote items ordered by id for determinism
|
||||
var quoteItems = await _context.Set<QuoteItem>()
|
||||
.IgnoreQueryFilters()
|
||||
.Include(qi => qi.Quote)
|
||||
.Where(qi => qi.CompanyId == company.Id
|
||||
&& !qi.IsDeleted
|
||||
&& qi.SurfaceAreaSqFt > 0
|
||||
&& !qi.IsLaborItem
|
||||
&& qi.AiPredictionId == null)
|
||||
.OrderBy(qi => qi.Id)
|
||||
.Take(8)
|
||||
.ToListAsync();
|
||||
|
||||
if (quoteItems.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Per-slot prediction specs — deterministic, varied across complexity/confidence tiers.
|
||||
// PredictedSqFt is intentionally close but NOT identical to the actual item SqFt so the
|
||||
// AI Accuracy report shows realistic prediction deltas.
|
||||
var specs = new[]
|
||||
{
|
||||
// slot 0 — complex automotive, AI nailed it
|
||||
( sqft: 13.5m, mins: 88, complexity: "Complex", confidence: "High",
|
||||
price: 125.00m, tags: "automotive,tubular,custom", rounds: 1, overrode: false,
|
||||
reasoning: "Detected a tubular motorcycle frame with multiple weld joints. High complexity due to intricate geometry and masking requirements around bearing surfaces. Confidence high — similar frames appear frequently in training data." ),
|
||||
|
||||
// slot 1 — wheel set, quick read, accepted as-is
|
||||
( sqft: 11.2m, mins: 42, complexity: "Simple", confidence: "High",
|
||||
price: 98.00m, tags: "automotive,wheels,aluminum", rounds: 1, overrode: false,
|
||||
reasoning: "Four aluminum wheels, uniform shape, minimal masking needed. Straightforward batch candidate for the main oven. Estimated surface area based on standard 18\" wheel profile." ),
|
||||
|
||||
// slot 2 — bumper job, user bumped sqft slightly
|
||||
( sqft: 14.8m, mins: 62, complexity: "Moderate", confidence: "Medium",
|
||||
price: 135.00m, tags: "automotive,bumper,off-road", rounds: 2, overrode: true,
|
||||
reasoning: "Steel off-road bumper and rock sliders. Moderate complexity — flat stock with mounting tabs. Second image round requested for accurate rock slider dimensions. User adjusted surface area slightly after physical measurement." ),
|
||||
|
||||
// slot 3 — large gate, low confidence, user corrected price
|
||||
( sqft: 34.2m, mins: 195, complexity: "Complex", confidence: "Low",
|
||||
price: 310.00m, tags: "architectural,gate,ornamental", rounds: 2, overrode: true,
|
||||
reasoning: "Wrought iron entry gate with decorative scrollwork. Low confidence due to depth ambiguity in photos — scrollwork surface area is difficult to estimate from images alone. Recommend physical measurement before finalising price. User overrode unit price after measuring on-site." ),
|
||||
|
||||
// slot 4 — patio furniture, solid read
|
||||
( sqft: 22.8m, mins: 52, complexity: "Moderate", confidence: "High",
|
||||
price: 195.00m, tags: "furniture,outdoor,patio", rounds: 1, overrode: false,
|
||||
reasoning: "Six-piece patio furniture set: four chairs, one table, one side table. Powder-coated tubular steel, standard outdoor finish. Good photo coverage — confidence high. Recommend Textured Beige or Satin Bronze for exterior durability." ),
|
||||
|
||||
// slot 5 — handrail, accepted price
|
||||
( sqft: 39.0m, mins: 118, complexity: "Moderate", confidence: "High",
|
||||
price: 342.00m, tags: "architectural,handrail,railing", rounds: 1, overrode: false,
|
||||
reasoning: "40-foot steel handrail system, square tube construction. Consistent profile makes area calculation straightforward. Standard Gloss Black most common finish for this application — confirmed with customer." ),
|
||||
|
||||
// slot 6 — brake calipers, small & simple
|
||||
( sqft: 3.8m, mins: 30, complexity: "Simple", confidence: "High",
|
||||
price: 65.00m, tags: "automotive,brake,caliper", rounds: 1, overrode: false,
|
||||
reasoning: "Set of four brake calipers, cast iron with machined mating surfaces. Masking required on piston bores and bleed nipples. Candy Red most requested finish. High confidence — calipers are a common item with well-established pricing." ),
|
||||
|
||||
// slot 7 — bicycle frame, two-round conversation
|
||||
( sqft: 6.1m, mins: 65, complexity: "Moderate", confidence: "Medium",
|
||||
price: 82.00m, tags: "recreational,bicycle,frame", rounds: 2, overrode: true,
|
||||
reasoning: "Road bicycle frame, aluminium alloy. Second image round needed to assess cable routing channels and dropout geometry. Moderate complexity due to small-radius bends. User adjusted surface area after AI initially underestimated top-tube length." )
|
||||
};
|
||||
|
||||
var seeded = 0;
|
||||
|
||||
for (int i = 0; i < quoteItems.Count && i < specs.Length; i++)
|
||||
{
|
||||
var item = quoteItems[i];
|
||||
var s = specs[i];
|
||||
|
||||
var prediction = new AiItemPrediction
|
||||
{
|
||||
PredictedSurfaceAreaSqFt = s.sqft,
|
||||
PredictedMinutes = s.mins,
|
||||
PredictedComplexity = s.complexity,
|
||||
PredictedUnitPrice = s.price,
|
||||
Confidence = s.confidence,
|
||||
Reasoning = s.reasoning,
|
||||
AiTags = s.tags,
|
||||
ConversationRounds = s.rounds,
|
||||
UserOverrodeEstimate = s.overrode,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = item.CreatedAt
|
||||
};
|
||||
|
||||
await _context.Set<AiItemPrediction>().AddAsync(prediction);
|
||||
await _context.SaveChangesAsync();
|
||||
seeded++;
|
||||
|
||||
// Mark the quote item as AI-analysed and link the prediction
|
||||
item.IsAiItem = true;
|
||||
item.AiPredictionId = prediction.Id;
|
||||
item.AiTags = s.tags;
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return seeded;
|
||||
}
|
||||
}
|
||||
@@ -337,15 +337,122 @@ public partial class SeedDataService
|
||||
var startDate = DateTime.Today;
|
||||
var appointmentTitles = new Dictionary<string, string[]>
|
||||
{
|
||||
["DROP_OFF"] = new[] { "Customer Drop-Off", "Parts Delivery", "Item Drop-Off", "Material Drop-Off" },
|
||||
["PICK_UP"] = new[] { "Customer Pick-Up", "Collection Appointment", "Order Pick-Up", "Completed Items Pick-Up" },
|
||||
["DROP_OFF"] = new[] { "Customer Drop-Off", "Parts Delivery", "Item Drop-Off", "Material Drop-Off" },
|
||||
["PICK_UP"] = new[] { "Customer Pick-Up", "Collection Appointment", "Order Pick-Up", "Completed Items Pick-Up" },
|
||||
["CONSULTATION"] = new[] { "Quote Discussion", "Project Consultation", "Initial Consultation", "Color Selection Meeting" },
|
||||
["JOB_WORK"] = new[] { "Sandblasting Session", "Coating Work", "Quality Inspection", "Final Finishing" }
|
||||
["JOB_WORK"] = new[] { "Sandblasting Session", "Coating Work", "Quality Inspection", "Final Finishing" }
|
||||
};
|
||||
|
||||
// Get status IDs by code for easy assignment
|
||||
var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
|
||||
var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id;
|
||||
var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
|
||||
var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id;
|
||||
var completedStatusId = appointmentStatuses.First(s => s.StatusCode == "COMPLETED").Id;
|
||||
var cancelledStatusId = appointmentStatuses.First(s => s.StatusCode == "CANCELLED").Id;
|
||||
var noShowStatusId = appointmentStatuses.First(s => s.StatusCode == "NO_SHOW").Id;
|
||||
var rescheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "RESCHEDULED").Id;
|
||||
|
||||
// ── PAST APPOINTMENTS (last 90 days) — Completed, Cancelled, No Show ──────
|
||||
// Walks backward through weekdays; ~40% chance of an appointment per day
|
||||
// for ~25 records spread naturally across the history window.
|
||||
var pastRandom = new Random(77); // separate seed keeps past/future independent
|
||||
var pastAppointmentSeq = 1;
|
||||
|
||||
static string? CancelNote(Random r)
|
||||
{
|
||||
var reasons = new[]
|
||||
{
|
||||
"Customer cancelled — rescheduling for next week.",
|
||||
"Customer cancelled — no reason given.",
|
||||
"Shop closed for equipment maintenance.",
|
||||
"Customer called to reschedule.",
|
||||
"Customer unavailable — will call back.",
|
||||
"Cancelled by shop — scheduling conflict."
|
||||
};
|
||||
return reasons[r.Next(reasons.Length)];
|
||||
}
|
||||
|
||||
for (int daysBack = 1; daysBack <= 90 && pastAppointmentSeq <= 25; daysBack++)
|
||||
{
|
||||
var pastDate = DateTime.Today.AddDays(-daysBack);
|
||||
if (pastDate.DayOfWeek == DayOfWeek.Saturday || pastDate.DayOfWeek == DayOfWeek.Sunday)
|
||||
continue;
|
||||
if (pastRandom.Next(100) >= 40) // ~40% chance = ~26 weekday hits over 90 days
|
||||
continue;
|
||||
|
||||
var aptType = appointmentTypes[pastRandom.Next(appointmentTypes.Count)];
|
||||
var customer = customers[pastRandom.Next(customers.Count)];
|
||||
|
||||
int startHour = pastRandom.Next(8, 17);
|
||||
int startMinute = pastRandom.Next(0, 4) * 15;
|
||||
var aptStart = new DateTime(pastDate.Year, pastDate.Month, pastDate.Day, startHour, startMinute, 0, DateTimeKind.Utc);
|
||||
int duration = pastRandom.Next(1, 5) * 30;
|
||||
var aptEnd = aptStart.AddMinutes(duration);
|
||||
|
||||
// 60% Completed, 25% Cancelled, 10% No Show, 5% Rescheduled
|
||||
int roll = pastRandom.Next(100);
|
||||
int pastStatusId;
|
||||
DateTime? actualStart = null, actualEnd = null;
|
||||
string? pastNotes = null;
|
||||
|
||||
if (roll < 60)
|
||||
{
|
||||
pastStatusId = completedStatusId;
|
||||
actualStart = aptStart.AddMinutes(pastRandom.Next(-5, 11)); // ±5–10 min variance
|
||||
actualEnd = aptEnd.AddMinutes(pastRandom.Next(-10, 16));
|
||||
}
|
||||
else if (roll < 85)
|
||||
{
|
||||
pastStatusId = cancelledStatusId;
|
||||
pastNotes = CancelNote(pastRandom);
|
||||
}
|
||||
else if (roll < 95)
|
||||
{
|
||||
pastStatusId = noShowStatusId;
|
||||
pastNotes = "Customer did not arrive. Follow-up call left.";
|
||||
}
|
||||
else
|
||||
{
|
||||
pastStatusId = rescheduledStatusId;
|
||||
pastNotes = "Rescheduled at customer request — see follow-up appointment.";
|
||||
}
|
||||
|
||||
// Optional job link (35% chance for past JOB_WORK; 15% for others)
|
||||
int? pastJobId = null;
|
||||
if (jobs.Any())
|
||||
{
|
||||
int linkChance = aptType.TypeCode == "JOB_WORK" ? 35 : 15;
|
||||
if (pastRandom.Next(100) < linkChance)
|
||||
pastJobId = jobs[pastRandom.Next(jobs.Count)].Id;
|
||||
}
|
||||
|
||||
string? assignedId = null;
|
||||
if (workers.Any() && pastRandom.Next(100) < 60)
|
||||
assignedId = workers[pastRandom.Next(workers.Count)].Id;
|
||||
|
||||
var pastLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
|
||||
var pastTitle = $"{pastLabel} — {appointmentTitles[aptType.TypeCode][pastRandom.Next(appointmentTitles[aptType.TypeCode].Length)]}";
|
||||
|
||||
appointments.Add(new Appointment
|
||||
{
|
||||
AppointmentNumber = $"APT-{pastDate:yyMM}-{pastAppointmentSeq++:D4}",
|
||||
CustomerId = customer.Id,
|
||||
JobId = pastJobId,
|
||||
AppointmentStatusId = pastStatusId,
|
||||
AppointmentTypeId = aptType.Id,
|
||||
AssignedUserId = assignedId,
|
||||
Title = pastTitle,
|
||||
ScheduledStartTime = aptStart,
|
||||
ScheduledEndTime = aptEnd,
|
||||
ActualStartTime = actualStart,
|
||||
ActualEndTime = actualEnd,
|
||||
IsAllDay = false,
|
||||
IsReminderEnabled = false, // reminders don't fire for past appointments
|
||||
ReminderMinutesBefore = 30,
|
||||
Notes = pastNotes,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = aptStart.AddDays(-pastRandom.Next(1, 8)) // booked 1–7 days ahead
|
||||
});
|
||||
}
|
||||
|
||||
// Generate 50 appointments across next 60 days (weekdays only)
|
||||
int appointmentsCreated = 0;
|
||||
@@ -388,7 +495,8 @@ public partial class SeedDataService
|
||||
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
|
||||
|
||||
// Title
|
||||
string title = $"{customer.CompanyName} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
|
||||
string customerLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
|
||||
string title = $"{customerLabel} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
|
||||
|
||||
// Optional job link (40% chance if type is JOB_WORK, 20% for others)
|
||||
int? jobId = null;
|
||||
|
||||
@@ -84,10 +84,11 @@ public partial class SeedDataService
|
||||
.Where(v => v.CompanyId == company.Id && !v.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.FirstOrDefault();
|
||||
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.FirstOrDefault();
|
||||
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.FirstOrDefault();
|
||||
var aceHardware = vendors.FirstOrDefault(v => v.CompanyName.Contains("Ace")) ?? vendors.FirstOrDefault();
|
||||
var fastenal = vendors.FirstOrDefault(v => v.CompanyName.Contains("Fastenal")) ?? vendors.FirstOrDefault();
|
||||
var grainger = vendors.FirstOrDefault(v => v.CompanyName.Contains("Grainger")) ?? vendors.FirstOrDefault();
|
||||
var harbor = vendors.FirstOrDefault(v => v.CompanyName.Contains("Harbor")) ?? vendors.FirstOrDefault();
|
||||
var localSupply = vendors.FirstOrDefault(v => v.CompanyName.Contains("Local")) ?? vendors.FirstOrDefault();
|
||||
var fallback = vendors.FirstOrDefault();
|
||||
|
||||
if (fallback == null)
|
||||
@@ -125,6 +126,105 @@ public partial class SeedDataService
|
||||
return bill;
|
||||
}
|
||||
|
||||
// ── ELECTRIC UTILITY — months −12 through −4 (paid) ─────────────────
|
||||
// Monthly bills going back 12 months fill the AP and expense trend charts.
|
||||
// Amounts reflect seasonal variation (higher summer AC, higher winter heat).
|
||||
var elecAmounts = new decimal[] { 640m, 620m, 595m, 580m, 570m, 558m, 545m, 562m, 574m };
|
||||
for (int m = 12; m >= 4; m--)
|
||||
{
|
||||
var bd = now.AddDays(-(m * 30 + 2));
|
||||
var dd = bd.AddDays(15);
|
||||
var pd = dd.AddDays(2);
|
||||
var amt = elecAmounts[12 - m];
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = $"ELEC-HIST-{m:D2}",
|
||||
VendorId = fallback.Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = bd,
|
||||
DueDate = dd,
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Due on Receipt",
|
||||
Memo = $"Electric — {m} months ago",
|
||||
SubTotal = amt,
|
||||
Total = amt,
|
||||
AmountPaid = amt,
|
||||
CreatedAt = bd,
|
||||
LineItems = { new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = amt, Amount = amt, DisplayOrder = 1 } }
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = fallback.Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = pd,
|
||||
Amount = amt,
|
||||
PaymentMethod = PaymentMethod.BankTransferACH,
|
||||
Memo = $"Electric bill — auto pay (month -{m})"
|
||||
});
|
||||
}
|
||||
|
||||
// ── POWDER ORDERS — months −12, −9, −6 (quarterly history) ───────────
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "PP-61041",
|
||||
VendorId = (prismatic ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-365),
|
||||
DueDate = now.AddDays(-335),
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Net 30",
|
||||
Memo = "Annual powder restock — month 12",
|
||||
SubTotal = 1_080.00m, Total = 1_080.00m, AmountPaid = 1_080.00m,
|
||||
CreatedAt = now.AddDays(-365),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 50 lbs", Quantity = 2, UnitPrice = 178.00m, Amount = 356.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 50 lbs", Quantity = 2, UnitPrice = 160.00m, Amount = 320.00m, DisplayOrder = 2 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Tape & Plugs Kit", Quantity = 2, UnitPrice = 152.00m, Amount = 304.00m, DisplayOrder = 3 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Straps Assortment", Quantity = 1, UnitPrice = 100.00m, Amount = 100.00m, DisplayOrder = 4 }
|
||||
}
|
||||
}, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-335), Amount = 1_080.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-61041 — paid in full" });
|
||||
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "PP-68820",
|
||||
VendorId = (columbia ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-275),
|
||||
DueDate = now.AddDays(-245),
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Net 30",
|
||||
Memo = "Quarterly specialty colors — month 9",
|
||||
SubTotal = 940.00m, Total = 940.00m, AmountPaid = 940.00m,
|
||||
CreatedAt = now.AddDays(-275),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Safety Yellow Powder — 25 lbs", Quantity = 2, UnitPrice = 115.00m, Amount = 230.00m, DisplayOrder = 2 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks & J-Hooks", Quantity = 1, UnitPrice = 275.00m, Amount = 275.00m, DisplayOrder = 3 }
|
||||
}
|
||||
}, new BillPayment { VendorId = (columbia ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-245), Amount = 940.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-68820 — paid in full" });
|
||||
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "PP-73110",
|
||||
VendorId = (prismatic ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-185),
|
||||
DueDate = now.AddDays(-155),
|
||||
Status = BillStatus.Paid,
|
||||
Terms = "Net 30",
|
||||
Memo = "Quarterly powder restock — month 6",
|
||||
SubTotal = 1_020.00m, Total = 1_020.00m, AmountPaid = 1_020.00m,
|
||||
CreatedAt = now.AddDays(-185),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 4, UnitPrice = 89.00m, Amount = 356.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 25 lbs", Quantity = 3, UnitPrice = 86.50m, Amount = 259.50m, DisplayOrder = 2 },
|
||||
new BillLineItem { AccountId = powderAccount?.Id, Description = "Satin Bronze Powder — 25 lbs", Quantity = 2, UnitPrice = 138.00m, Amount = 276.00m, DisplayOrder = 3 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Wire Brushes & Abrasives",Quantity = 1, UnitPrice = 128.50m, Amount = 128.50m, DisplayOrder = 4 }
|
||||
}
|
||||
}, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-155), Amount = 1_020.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-73110 — paid in full" });
|
||||
|
||||
// ── POWDER ORDERS ─────────────────────────────────────────────────────
|
||||
|
||||
// Month -3: Large powder restock — Paid
|
||||
@@ -270,11 +370,11 @@ public partial class SeedDataService
|
||||
|
||||
// ── CONSUMABLES / HARDWARE ─────────────────────────────────────────────
|
||||
|
||||
// Month -3: Fastenal hardware — Paid
|
||||
// Month -3: Harbor Freight consumables — Paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "FST-18822",
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorInvoiceNumber = "HBF-18822",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-85),
|
||||
DueDate = now.AddDays(-55),
|
||||
@@ -293,20 +393,20 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-55),
|
||||
Amount = 412.50m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1082",
|
||||
Memo = "FST-18822 — paid in full"
|
||||
Memo = "HBF-18822 — paid in full"
|
||||
});
|
||||
|
||||
// Month -1: Fastenal — Paid
|
||||
// Month -1: Harbor Freight — Paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "FST-20041",
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorInvoiceNumber = "HBF-20041",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-40),
|
||||
DueDate = now.AddDays(-10),
|
||||
@@ -325,20 +425,20 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-10),
|
||||
Amount = 298.00m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1086",
|
||||
Memo = "FST-20041 — paid in full"
|
||||
Memo = "HBF-20041 — paid in full"
|
||||
});
|
||||
|
||||
// Current: Fastenal — Open
|
||||
// Current: Harbor Freight — Open
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "FST-20441",
|
||||
VendorId = (fastenal ?? fallback).Id,
|
||||
VendorInvoiceNumber = "HBF-20441",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-7),
|
||||
DueDate = now.AddDays(23),
|
||||
@@ -361,8 +461,8 @@ public partial class SeedDataService
|
||||
// Month -3: Sandblaster service — Paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "ACE-6901",
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorInvoiceNumber = "GRG-6901",
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-80),
|
||||
DueDate = now.AddDays(-50),
|
||||
@@ -380,20 +480,20 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-50),
|
||||
Amount = 310.00m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1079",
|
||||
Memo = "ACE-6901 — sandblaster parts"
|
||||
Memo = "GRG-6901 — sandblaster parts"
|
||||
});
|
||||
|
||||
// Month -1: Oven repair — Partially paid
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "ACE-7714",
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorInvoiceNumber = "GRG-7714",
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-20),
|
||||
DueDate = now.AddDays(10),
|
||||
@@ -411,13 +511,13 @@ public partial class SeedDataService
|
||||
}
|
||||
}, new BillPayment
|
||||
{
|
||||
VendorId = (aceHardware ?? fallback).Id,
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
BankAccountId = checkingAccount.Id,
|
||||
PaymentDate = now.AddDays(-10),
|
||||
Amount = 200.00m,
|
||||
PaymentMethod = PaymentMethod.Check,
|
||||
CheckNumber = "1087",
|
||||
Memo = "ACE-7714 — partial payment"
|
||||
Memo = "GRG-7714 — partial payment"
|
||||
});
|
||||
|
||||
// ── UTILITIES (3 months each) ─────────────────────────────────────────
|
||||
@@ -509,6 +609,74 @@ public partial class SeedDataService
|
||||
Memo = "Electric bill — auto pay"
|
||||
});
|
||||
|
||||
// ── AP AGING DEMO BILLS ───────────────────────────────────────────────
|
||||
// These open/unpaid bills populate all four AP aging buckets for report demos.
|
||||
|
||||
// 30-60 day bucket: consumables that slipped through AP (~40 days past due)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "HBF-19900",
|
||||
VendorId = (harbor ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-70),
|
||||
DueDate = now.AddDays(-40),
|
||||
Status = BillStatus.Open,
|
||||
Terms = "Net 30",
|
||||
Memo = "Shop consumables — unpaid (aging demo)",
|
||||
SubTotal = 228.50m,
|
||||
Total = 228.50m,
|
||||
AmountPaid = 0m,
|
||||
CreatedAt = now.AddDays(-70),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Silicone Plugs Assortment", Quantity = 2, UnitPrice = 64.25m, Amount = 128.50m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Strap Replacements", Quantity = 1, UnitPrice = 100.00m, Amount = 100.00m, DisplayOrder = 2 }
|
||||
}
|
||||
});
|
||||
|
||||
// 61-90 day bucket: blast media from Local Industrial Supply (~72 days past due)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "LIS-3301",
|
||||
VendorId = (localSupply ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-102),
|
||||
DueDate = now.AddDays(-72),
|
||||
Status = BillStatus.Open,
|
||||
Terms = "Net 30",
|
||||
Memo = "Aluminum oxide blast media — unpaid (aging demo)",
|
||||
SubTotal = 385.00m,
|
||||
Total = 385.00m,
|
||||
AmountPaid = 0m,
|
||||
CreatedAt = now.AddDays(-102),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Aluminum Oxide #80 Grit — 100 lb bag", Quantity = 5, UnitPrice = 77.00m, Amount = 385.00m, DisplayOrder = 1 }
|
||||
}
|
||||
});
|
||||
|
||||
// 90+ day bucket: old Grainger equipment parts order (~98 days past due)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
VendorInvoiceNumber = "GRG-5001",
|
||||
VendorId = (grainger ?? fallback).Id,
|
||||
APAccountId = apAccount.Id,
|
||||
BillDate = now.AddDays(-128),
|
||||
DueDate = now.AddDays(-98),
|
||||
Status = BillStatus.Open,
|
||||
Terms = "Net 30",
|
||||
Memo = "Oven conveyor motor parts — unpaid (aging demo)",
|
||||
SubTotal = 492.00m,
|
||||
Total = 492.00m,
|
||||
AmountPaid = 0m,
|
||||
CreatedAt = now.AddDays(-128),
|
||||
LineItems =
|
||||
{
|
||||
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Conveyor Drive Motor 1/2 HP", Quantity = 1, UnitPrice = 312.00m, Amount = 312.00m, DisplayOrder = 1 },
|
||||
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Drive Chain Sprocket (2-pack)", Quantity = 1, UnitPrice = 180.00m, Amount = 180.00m, DisplayOrder = 2 }
|
||||
}
|
||||
});
|
||||
|
||||
// Electric — current month (open)
|
||||
await AddBill(new Bill
|
||||
{
|
||||
@@ -625,50 +793,55 @@ public partial class SeedDataService
|
||||
seeded++;
|
||||
}
|
||||
|
||||
// ── SHOP RENT — 3 months ──────────────────────────────────────────────
|
||||
// ── SHOP RENT — 12 months ─────────────────────────────────────────────
|
||||
// Monthly rent payments going back 12 months fill the P&L expense chart.
|
||||
var rentAccount = rentAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-95), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 3 months ago");
|
||||
await AddExp(now.AddDays(-65), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 2 months ago");
|
||||
await AddExp(now.AddDays(-35), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — last month");
|
||||
await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month");
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 + 2)), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, $"Shop rent — {m} month{(m == 1 ? "" : "s")} ago");
|
||||
await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month");
|
||||
|
||||
// ── NATURAL GAS — 3 months ────────────────────────────────────────────
|
||||
// ── NATURAL GAS — 12 months ───────────────────────────────────────────
|
||||
// Gas usage varies seasonally — higher in winter months.
|
||||
var utilAccount = utilitiesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-88), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 218.44m, "Natural gas — 3 months ago");
|
||||
await AddExp(now.AddDays(-58), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 241.60m, "Natural gas — 2 months ago");
|
||||
await AddExp(now.AddDays(-28), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 196.30m, "Natural gas — last month");
|
||||
decimal[] gasAmts = [310m, 295m, 260m, 218m, 185m, 172m, 168m, 179m, 196m, 225m, 255m, 241m];
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 - 2)), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, gasAmts[12 - m], $"Natural gas — {m} month{(m == 1 ? "" : "s")} ago");
|
||||
|
||||
// ── INSURANCE ─────────────────────────────────────────────────────────
|
||||
// ── INSURANCE — quarterly (4 payments over 12 months) ─────────────────
|
||||
var insAccount = insuranceAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-90), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1");
|
||||
await AddExp(now.AddDays(-1), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
|
||||
await AddExp(now.AddDays(-365), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1 (prior year)");
|
||||
await AddExp(now.AddDays(-274), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
|
||||
await AddExp(now.AddDays(-182), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q3");
|
||||
await AddExp(now.AddDays(-91), null, insAccount, checkingAccount, PaymentMethod.Check, 810.00m, "Business liability insurance — quarterly premium Q4 (rate increase)");
|
||||
|
||||
// ── MARKETING / ADVERTISING ───────────────────────────────────────────
|
||||
// ── MARKETING / ADVERTISING — monthly for 12 months ──────────────────
|
||||
var adAccount = advertisingAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-80), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
|
||||
await AddExp(now.AddDays(-50), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign");
|
||||
await AddExp(now.AddDays(-20), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — expanded local campaign");
|
||||
await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly");
|
||||
decimal[] adAmts = [120m, 120m, 135m, 150m, 150m, 165m, 150m, 165m, 175m, 175m, 175m, 175m];
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 + 5)), null, adAccount, cc, PaymentMethod.CreditDebitCard, adAmts[12 - m], "Google Ads — local search campaign");
|
||||
await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly");
|
||||
await AddExp(now.AddDays(-5), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — current month");
|
||||
|
||||
// ── SOFTWARE SUBSCRIPTIONS ────────────────────────────────────────────
|
||||
// ── SOFTWARE SUBSCRIPTIONS — 12 months ───────────────────────────────
|
||||
var swAccount = officeSuppliesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-90), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-60), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-30), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30)), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
|
||||
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — current month");
|
||||
|
||||
// ── OFFICE SUPPLIES ───────────────────────────────────────────────────
|
||||
// ── OFFICE SUPPLIES — quarterly ───────────────────────────────────────
|
||||
var offAccount = officeSuppliesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-75), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-8), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
|
||||
var firstVendorId = vendors.FirstOrDefault()?.Id;
|
||||
await AddExp(now.AddDays(-270), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 58.90m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-180), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-90), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 71.25m, "Office supplies — printer paper, labels, pens");
|
||||
await AddExp(now.AddDays(-8), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
|
||||
|
||||
// ── BANK FEES ─────────────────────────────────────────────────────────
|
||||
// ── BANK FEES — 12 months ─────────────────────────────────────────────
|
||||
var bankAccount = bankChargesAcct ?? fallbackExpense;
|
||||
await AddExp(now.AddDays(-85), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 28.50m, "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-55), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 31.20m, "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-25), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 29.80m, "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees");
|
||||
decimal[] feeAmts = [24m, 25m, 26m, 28m, 27m, 29m, 28m, 30m, 31m, 29m, 31m, 32m];
|
||||
for (int m = 12; m >= 1; m--)
|
||||
await AddExp(now.AddDays(-(m * 30 - 1)), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, feeAmts[12 - m], "Monthly card processing fees");
|
||||
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees — current month");
|
||||
|
||||
return seeded;
|
||||
}
|
||||
|
||||
@@ -6,53 +6,37 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 100 realistic customers (60 commercial, 40 individual/non-commercial) for
|
||||
/// the given company, spanning automotive, industrial, architectural, fitness, marine,
|
||||
/// furniture, government, and specialty verticals.
|
||||
/// Seeds customers at a rate of 15 per calendar month from January of the current year
|
||||
/// through the current month, mimicking a real shop that acquires customers steadily
|
||||
/// as the year progresses. Seeding in January produces 15 customers; seeding in June
|
||||
/// produces 90 (15 × 6 months).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotency: returns (0, empty warnings) immediately if any non-deleted customers already
|
||||
/// exist for this company, preventing duplicate customer sets on repeated seed runs.
|
||||
/// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
|
||||
/// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/>
|
||||
/// maps customer indices 0–9 to specific commercial price profiles, so the commercial
|
||||
/// anchors must always occupy the lowest database IDs for this company.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each customer is inserted individually (rather than in a single <c>AddRange</c>) so that
|
||||
/// a duplicate-email collision on any single record is caught and converted to a warning
|
||||
/// rather than aborting the entire batch. The EF entity is detached on failure to prevent
|
||||
/// the DbContext change-tracker from retrying the failed insert on the next
|
||||
/// <c>SaveChangesAsync</c> call.
|
||||
/// Remaining slots in each month (15 − anchors_that_month) are filled with procedurally
|
||||
/// generated individual customers drawn from NC-area name and city pools, so the
|
||||
/// New Customers per Month chart shows a consistent 15-bar pattern regardless of
|
||||
/// the reseed date.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pricing tiers (Standard, Silver, Gold, Platinum) are resolved by name from the company's
|
||||
/// already-seeded tiers. If a tier is missing the customer still inserts with no tier,
|
||||
/// rather than throwing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Government/municipal customers (<c>Metro Transit Authority</c>, <c>Municipal Services Group</c>,
|
||||
/// <c>Regional Airport Authority</c>, <c>County School District</c>) are seeded with
|
||||
/// <c>IsTaxExempt = true</c> to demonstrate the tax-exempt workflow, matching the
|
||||
/// production rule that tax-exempt customers get 0 % tax on quotes and invoices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two local helper functions <c>Comm()</c> and <c>Indiv()</c> reduce the per-row
|
||||
/// line count; they are defined as local functions rather than private methods because
|
||||
/// they capture the <c>company</c> parameter by closure and are only needed here.
|
||||
/// Idempotency: returns 0 immediately if any non-deleted customers already exist for
|
||||
/// this company (they are removed by the reset sweep before a full reseed).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed customers for.</param>
|
||||
/// <returns>
|
||||
/// A tuple of (<c>seededCount</c>, <c>warnings</c>) where <c>seededCount</c> is the number
|
||||
/// of records actually inserted and <c>warnings</c> lists any customers that were skipped
|
||||
/// (e.g. because the email already existed).
|
||||
/// </returns>
|
||||
/// <returns>A tuple of (seededCount, warnings) where warnings list skipped records.</returns>
|
||||
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
|
||||
{
|
||||
var warnings = new List<string>();
|
||||
int seededCount = 0;
|
||||
int skippedCount = 0;
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Early exit — same pattern as all other seeders
|
||||
var existingCount = await _context.Set<Customer>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted);
|
||||
@@ -70,191 +54,176 @@ public partial class SeedDataService
|
||||
var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
|
||||
var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum");
|
||||
|
||||
// ── Local helpers keep each customer to 2-3 lines ──────────────────────
|
||||
//
|
||||
// Comm() builds a commercial (B2B) Customer with credit limit, tax ID, and pricing tier.
|
||||
// The LastContactDate formula scrambles the months value so that contacts are spread
|
||||
// across the past 25 days rather than clustering on the same date for all customers.
|
||||
// ── Anchor customer builders (commercial and individual) ──────────────
|
||||
Customer Comm(string co, string fn, string ln, string em, string ph,
|
||||
string city, string st, string zip, string terms, decimal credit, decimal bal,
|
||||
string tax, PricingTier? tier, string notes, int months, bool taxExempt = false) =>
|
||||
string tax, PricingTier? tier, string notes, bool taxExempt = false) =>
|
||||
new Customer
|
||||
{
|
||||
CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em,
|
||||
Phone = ph, City = city, State = st, ZipCode = zip,
|
||||
IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal,
|
||||
PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt,
|
||||
IsActive = true,
|
||||
LastContactDate = now.AddDays(-((months * 11 + 3) % 25 + 1)),
|
||||
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
|
||||
IsActive = true, GeneralNotes = notes, CompanyId = company.Id
|
||||
};
|
||||
|
||||
// Indiv() builds a non-commercial (retail) Customer with simpler fields:
|
||||
// no credit limit, no tax ID, payment terms default to "Due on receipt".
|
||||
Customer Indiv(string fn, string ln, string em, string ph,
|
||||
string city, string st, string zip, string notes, int months) =>
|
||||
string city, string st, string zip, string notes) =>
|
||||
new Customer
|
||||
{
|
||||
ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph,
|
||||
City = city, State = st, ZipCode = zip,
|
||||
IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true,
|
||||
LastContactDate = now.AddDays(-((months * 17 + 5) % 50 + 5)),
|
||||
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
|
||||
GeneralNotes = notes, CompanyId = company.Id
|
||||
};
|
||||
|
||||
var customers = new List<Customer>
|
||||
// ── 27 hand-crafted anchors — ORDER IS CRITICAL ───────────────────────
|
||||
// SeedJobsAsync.CustomerProfile() maps indices 0–9 to commercial accounts
|
||||
// and 10–26 to known individual accounts. Never reorder these.
|
||||
var anchors = new List<Customer>
|
||||
{
|
||||
// ─── Commercial Customers (60) ────────────────────────────────────
|
||||
// Commercial (ci 0–9) — drive the Revenue by Customer report story
|
||||
Comm("Carolina Fabrication", "Matt", "Henderson", "matt@carolinafab.com", "(919) 234-5678", "Raleigh", "NC", "27601", "Net 30", 50000m, 3200m, "56-7890123", platinumTier, "Largest account; structural steel and custom fab runs weekly"),
|
||||
Comm("Apex Motorsports", "Chris", "Tanner", "ctanner@apexmotorsports.com", "(919) 345-6789", "Apex", "NC", "27502", "Net 30", 35000m, 2100m, "23-4567890", goldTier, "Race parts, chassis work, performance components"),
|
||||
Comm("Triangle Offroad", "Jason", "Pruitt", "jpruitt@triangleoffroad.com", "(919) 456-7890", "Durham", "NC", "27701", "Net 15", 30000m, 1800m, "34-5678901", goldTier, "Jeep and truck accessory coatings; skid plates, bumpers"),
|
||||
Comm("Smith Welding & Steel", "Bill", "Smith", "bsmith@smithwelding.com", "(919) 567-8901", "Garner", "NC", "27529", "Net 30", 20000m, 950m, "45-6789012", silverTier, "Custom steel fab, gates, railings; repeat orders monthly"),
|
||||
Comm("Raleigh Architectural Metals", "Karen", "Morales", "kmorales@raleigharchitectural.com", "(919) 678-9012", "Raleigh", "NC", "27604", "Net 30", 25000m, 1400m, "67-8901234", goldTier, "Decorative ironwork, balcony railings, entry gates"),
|
||||
Comm("East Coast Powderworks", "Tony", "Greco", "tgreco@eastcoastpw.com", "(252) 789-0123", "Greenville", "NC", "27858", "Net 15", 15000m, 620m, "78-9012345", silverTier, "Metal fab and fabrication outsourcing"),
|
||||
Comm("Piedmont Metal Works", "Derek", "Shaw", "dshaw@piedmontmetalworks.com", "(336) 890-1234", "Greensboro", "NC", "27401", "Net 30", 10000m, 380m, "89-0123456", standardTier, "Heavy industrial parts, machine guards, conveyor frames"),
|
||||
Comm("Cary Industrial Solutions", "Linda", "Patel", "lpatel@caryindustrial.com", "(919) 901-2345", "Cary", "NC", "27511", "Net 30", 18000m, 870m, "90-1234567", silverTier, "Equipment casings, pump housings, control panels"),
|
||||
Comm("Durham Tech Equipment", "Ryan", "Blake", "rblake@durhamtech.com", "(919) 012-3456", "Durham", "NC", "27703", "Net 30", 28000m, 1150m, "01-2345678", goldTier, "Lab and research equipment frames and enclosures"),
|
||||
Comm("Wake County Fleet Services", "Michelle", "Coleman", "mcoleman@wakecountyfleet.gov", "(919) 123-4560", "Raleigh", "NC", "27602", "Net 60", 75000m, 2800m, "56-7890124", platinumTier, "Government fleet contract — tax exempt; trailers, truck beds", true),
|
||||
|
||||
// Auto & Motorsports (12)
|
||||
Comm("Acme Manufacturing Corp", "John", "Smith", "john.smith@acmemfg.com", "(555) 234-5678", "Chicago", "IL", "60601", "Net 30", 50000m, 12500m, "12-3456789", platinumTier, "Large volume customer, weekly shipments", 18),
|
||||
Comm("Precision Auto Parts LLC", "Sarah", "Johnson", "sjohnson@precisionauto.com", "(555) 345-6789", "Detroit", "MI", "48201", "Net 30", 35000m, 8750m, "23-4567890", goldTier, "Automotive parts manufacturer", 15),
|
||||
Comm("Classic Wheel Restoration", "Robert", "Taylor", "rtaylor@classicwheels.com", "(555) 789-0123", "Phoenix", "AZ", "85001", "Net 15", 15000m, 3200m, "67-8901234", silverTier, "Classic car wheel specialist", 10),
|
||||
Comm("MotorSports Custom Shop", "Chris", "Brown", "cbrown@motorsportscustom.com", "(555) 901-2345", "Indianapolis", "IN", "46201", "Net 15", 20000m, 9500m, "89-0123456", silverTier, "Performance parts and custom fabrication", 8),
|
||||
Comm("Metro Automotive Group", "Frank", "DeNucci", "frank.dnucci@metroauto.com", "(555) 210-3311", "Detroit", "MI", "48202", "Net 30", 28000m, 6400m, "14-2233445", goldTier, "Multi-brand dealership network", 11),
|
||||
Comm("Coastal Customs & Fabrication", "Danny", "Morales", "dmorales@coastalcustoms.com", "(555) 887-6543", "San Diego", "CA", "92103", "Net 15", 18000m, 4100m, "22-3344556", silverTier, "Custom truck and SUV builds", 6),
|
||||
Comm("Desert Speed Shop", "Kyle", "Rennick", "kyle@desertspeedshop.com", "(555) 766-5544", "Scottsdale", "AZ", "85251", "Net 15", 12000m, 2800m, "33-4455667", standardTier, "Performance tuning and fabrication", 4),
|
||||
Comm("Midwest Motorsports", "Troy", "Edelmann", "troy@midwestmotorsports.com", "(555) 342-1122", "Columbus", "OH", "43201", "Net 30", 22000m, 5300m, "44-5566778", goldTier, "Racing team equipment and parts", 9),
|
||||
Comm("Track Day Performance", "Megan", "Schultz", "megan@trackdayperformance.com", "(555) 456-9988", "Charlotte", "NC", "28201", "Net 15", 16000m, 3700m, "55-6677889", silverTier, "Track prep and safety equipment", 5),
|
||||
Comm("Vintage Velocity Restorations", "Harold", "Pearce", "harold@vintagevelocity.com", "(555) 321-7654", "Nashville", "TN", "37201", "Net 30", 25000m, 6100m, "66-7788990", goldTier, "High-end vintage and classic car restoration", 13),
|
||||
Comm("American Iron Custom Cycles", "Bret", "Conner", "bret@americanironcycles.com", "(555) 654-3210", "Milwaukee", "WI", "53201", "Net 15", 14000m, 3100m, "77-8899001", silverTier, "Custom motorcycle builds and parts", 7),
|
||||
Comm("All-American Auto Body", "Steve", "Kozlowski", "steve@allamericanautobody.com", "(555) 213-4567", "Cleveland", "OH", "44101", "Net 30", 20000m, 4800m, "88-9900112", goldTier, "Collision repair and custom coating", 10),
|
||||
|
||||
// Industrial & Manufacturing (10)
|
||||
Comm("Industrial Furniture Co", "Jennifer","Anderson", "janderson@indfurniture.com", "(555) 890-1234", "Seattle", "WA", "98101", "Net 30", 30000m, 7800m, "78-9012345", goldTier, "Office and outdoor furniture manufacturer", 16),
|
||||
Comm("Commercial HVAC Systems", "Kevin", "Garcia", "kgarcia@commercialhvac.com", "(555) 345-6780", "Atlanta", "GA", "30301", "Net 30", 32000m, 8900m, "23-4567891", goldTier, "HVAC ductwork and equipment casings", 17),
|
||||
Comm("Agricultural Equipment Inc", "Sandra", "White", "swhite@agequipment.com", "(555) 678-9013", "Des Moines", "IA", "50301", "Net 30", 42000m, 16800m, "56-7890124", goldTier, "Farm equipment parts and implements", 19),
|
||||
Comm("Steel City Fabricators", "Tony", "Marchetti", "tony@steelcityfab.com", "(555) 412-3344", "Pittsburgh", "PA", "15201", "Net 30", 38000m, 10200m, "99-0011223", platinumTier, "Heavy structural steel fabrication", 14),
|
||||
Comm("Precision Metal Works", "Diane", "Tran", "diane@precisionmetalworks.com", "(555) 503-2211", "Portland", "OR", "97205", "Net 30", 26000m, 5900m, "10-1122334", goldTier, "CNC machined parts and assemblies", 12),
|
||||
Comm("Continental Manufacturing", "Phil", "Stavros", "pstavros@continentalmfg.com", "(555) 216-8877", "Cleveland", "OH", "44115", "Net 45", 55000m, 18400m, "21-2233445", platinumTier, "Industrial component manufacturing", 20),
|
||||
Comm("Eagle Industrial Coatings", "Deb", "Hensley", "deb@eagleindustrialcoatings.com", "(555) 317-5566", "Indianapolis", "IN", "46204", "Net 30", 19000m, 4300m, "32-3344556", silverTier, "Subcontract coating for industrial parts", 7),
|
||||
Comm("Summit Metal Fabricators", "Russ", "Fontaine", "russ@summitmetalfab.com", "(555) 720-4433", "Denver", "CO", "80202", "Net 30", 31000m, 7700m, "43-4455667", goldTier, "Custom metal fabrication and welding", 11),
|
||||
Comm("Iron Horse Manufacturing", "Craig", "Bukowski", "craig@ironhorsemfg.com", "(555) 414-7788", "Milwaukee", "WI", "53202", "Net 30", 24000m, 5600m, "54-5566778", goldTier, "Heavy equipment components and frames", 9),
|
||||
Comm("Pacific Metal Works", "Yuki", "Tanaka", "ytanaka@pacificmetalworks.com", "(555) 206-3322", "Seattle", "WA", "98104", "Net 15", 17000m, 3800m, "65-6677889", silverTier, "Sheet metal fabrication and finishing", 6),
|
||||
|
||||
// Architectural & Construction (8)
|
||||
Comm("Urban Railings & Gates", "Michael", "Chen", "mchen@urbanrailings.com", "(555) 456-7890", "San Francisco", "CA", "94102", "Net 15", 25000m, 5200m, "34-5678901", silverTier, "Ornamental iron railings and gates", 12),
|
||||
Comm("Heritage Architectural Metalworks","Thomas","Miller", "tmiller@heritagemetal.com", "(555) 123-4567", "Charleston", "SC", "29401", "Net 30", 28000m, 6700m, "01-2345678", goldTier, "Historic restoration and custom architectural pieces",13),
|
||||
Comm("Skyline Structural Steel", "Marcus", "Webb", "mwebb@skylinsteel.com", "(555) 312-9876", "Chicago", "IL", "60607", "Net 45", 65000m, 22000m, "76-7788990", platinumTier, "Commercial and industrial structural steel", 22),
|
||||
Comm("Premier Fence & Gate Co", "Lori", "Hale", "lori@premierfenceandgate.com", "(555) 602-1199", "Phoenix", "AZ", "85004", "Net 30", 23000m, 5400m, "87-8899001", goldTier, "Residential and commercial fencing", 8),
|
||||
Comm("Modern Railing Systems", "Evan", "Choi", "echoi@modernrailingsystems.com", "(555) 415-8844", "San Jose", "CA", "95110", "Net 30", 27000m, 6200m, "98-9900112", goldTier, "Interior and exterior railing design", 10),
|
||||
Comm("Coastal Aluminum Products", "Patty", "Larson", "plarson@coastalaluminum.com", "(555) 904-3366", "Tampa", "FL", "33601", "Net 30", 21000m, 4700m, "09-0011223", goldTier, "Aluminum windows, doors, and structures", 7),
|
||||
Comm("Metro Door & Window", "Sam", "Petrov", "spetrov@metrodoorwindow.com", "(555) 718-4455", "Brooklyn", "NY", "11201", "Net 30", 29000m, 7100m, "20-1122334", goldTier, "Commercial door and window systems", 11),
|
||||
Comm("Rocky Mountain Ironworks", "Buck", "Ramsey", "buck@rockymtnironworks.com", "(555) 303-6677", "Denver", "CO", "80203", "Net 15", 18500m, 4100m, "31-2233445", silverTier, "Custom wrought iron and steel artisan work", 5),
|
||||
|
||||
// Fitness & Recreation (5)
|
||||
Comm("Fitness Equipment Solutions", "Lisa", "Martinez", "lmartinez@fitequip.com", "(555) 567-8901", "Austin", "TX", "78701", "Net 30", 40000m, 15600m, "45-6789012", goldTier, "Gym equipment frames and accessories", 14),
|
||||
Comm("Playground Equipment USA", "Nancy", "Martinez", "nmartinez@playgroundusa.com", "(555) 456-7891", "Portland", "OR", "97201", "Net 30", 38000m, 14500m, "34-5678902", platinumTier, "Commercial playground equipment manufacturer", 22),
|
||||
Comm("Diamond Fitness Equipment", "Lamar", "Okafor", "lamar@diamondfitness.com", "(555) 713-2288", "Houston", "TX", "77002", "Net 30", 33000m, 8100m, "42-3344556", goldTier, "Commercial gym and fitness center equipment", 9),
|
||||
Comm("Peak Performance Products", "Stacy", "Owens", "stacy@peakperformanceproducts.com", "(555) 503-7711", "Eugene", "OR", "97401", "Net 15", 16000m, 3500m, "53-4455667", silverTier, "Outdoor fitness and sports equipment", 6),
|
||||
Comm("All-Star Sports Equipment", "Jerome", "Watkins", "jwatkins@allstarsports.com", "(555) 314-5533", "St. Louis", "MO", "63101", "Net 30", 22000m, 5100m, "64-5566778", goldTier, "Team sports equipment and facilities", 8),
|
||||
|
||||
// Marine (4)
|
||||
Comm("Marine Equipment Corp", "Patricia","Wilson", "pwilson@marineequip.com", "(555) 234-5679", "Miami", "FL", "33101", "Net 30", 35000m, 11400m, "12-3456780", silverTier, "Boat hardware and marine fittings", 11),
|
||||
Comm("Gulf Coast Marine Supply", "Hector", "Vega", "hvega@gulfcoastmarine.com", "(555) 985-6644", "New Orleans", "LA", "70112", "Net 30", 28000m, 6800m, "75-6677889", goldTier, "Commercial and recreational marine hardware", 9),
|
||||
Comm("Pacific Yacht Hardware", "Erin", "Nakamura", "enakamura@pacificyacht.com", "(555) 310-8822", "Long Beach", "CA", "90802", "Net 15", 20000m, 4500m, "86-7788990", silverTier, "High-end yacht fittings and hardware", 6),
|
||||
Comm("Lakeside Boat Works", "Walt", "Bauer", "walt@lakesideboatworks.com", "(555) 616-3311", "Grand Rapids", "MI", "49501", "Net 30", 15000m, 3200m, "97-8899001", silverTier, "Freshwater boat repair and custom builds", 4),
|
||||
|
||||
// Furniture & Commercial (5)
|
||||
Comm("Office Systems International", "Brian", "Lee", "blee@officesystems.com", "(555) 567-8902", "Dallas", "TX", "75201", "Net 15", 27000m, 5600m, "45-6789013", silverTier, "Office furniture components and accessories", 9),
|
||||
Comm("Retail Display Solutions", "Gina", "Russo", "gruso@retaildisplay.com", "(555) 312-6644", "Chicago", "IL", "60608", "Net 30", 19000m, 4300m, "08-9900112", goldTier, "Retail shelving, fixtures, and displays", 7),
|
||||
Comm("Restaurant Equipment Co", "Marco", "Benetti", "mbenetti@restaurantequipment.com", "(555) 305-1122", "Miami", "FL", "33102", "Net 30", 24000m, 5800m, "19-0011223", goldTier, "Commercial kitchen and restaurant equipment", 10),
|
||||
Comm("Outdoor Living Products", "Cheryl", "Dobbs", "cdobbs@outdoorlivingproducts.com", "(555) 480-7799", "Tempe", "AZ", "85281", "Net 30", 21000m, 4600m, "30-1122334", goldTier, "Patio and outdoor furniture manufacturer", 8),
|
||||
Comm("Commercial Shelving Systems", "Ray", "Obasi", "robasi@commercialshelving.com", "(555) 832-5544", "Houston", "TX", "77003", "Net 30", 16000m, 3400m, "41-2233445", silverTier, "Warehouse and retail shelving solutions", 5),
|
||||
|
||||
// Energy, Transit & Government (7)
|
||||
Comm("Metro Transit Authority", "David", "Williams", "dwilliams@metrota.gov", "(555) 678-9012", "Boston", "MA", "02101", "Net 60", 75000m, 22000m, "56-7890123", platinumTier, "Government transit contract — tax exempt", 24, true),
|
||||
Comm("Green Energy Solutions", "Amanda", "Davis", "adavis@greenenergy.com", "(555) 012-3456", "Denver", "CO", "80201", "Net 30", 45000m, 18200m, "90-1234567", platinumTier, "Solar panel frames and mounting hardware", 20),
|
||||
Comm("Solar Power Systems Inc", "Neil", "Ostrowski", "nostrowski@solarpowersys.com", "(555) 408-4411", "San Jose", "CA", "95112", "Net 30", 36000m, 9200m, "52-3344556", goldTier, "Solar racking and structural components", 11),
|
||||
Comm("Wind Energy Components", "Tara", "Haas", "thaas@windenergy.com", "(555) 605-8833", "Austin", "TX", "78702", "Net 45", 48000m, 15600m, "63-4455667", platinumTier, "Wind turbine hardware and mounting systems", 16),
|
||||
Comm("Municipal Services Group", "Roy", "Nkosi", "rnkosi@municipalservices.gov", "(555) 608-2233", "Sacramento", "CA", "95814", "Net 60", 60000m, 19800m, "74-5566778", platinumTier, "City infrastructure and public works — tax exempt", 28, true),
|
||||
Comm("Regional Airport Authority", "Lisa", "Crane", "lcrane@regionairport.gov", "(555) 904-5511", "Tampa", "FL", "33602", "Net 60", 55000m, 17200m, "85-6677889", platinumTier, "Airport infrastructure — tax exempt", 21, true),
|
||||
Comm("County School District", "Terry", "Vance", "tvance@countyschools.edu", "(555) 317-8866", "Indianapolis", "IN", "46205", "Net 60", 40000m, 12500m, "96-7788990", goldTier, "School facility equipment — tax exempt", 15, true),
|
||||
|
||||
// Specialty (9)
|
||||
Comm("Medical Equipment Corp", "Paula", "Jennings", "pjennings@medicalequip.com", "(555) 215-6655", "Philadelphia", "PA", "19103", "Net 30", 42000m, 12800m, "07-8899001", goldTier, "Medical and laboratory equipment frames", 13),
|
||||
Comm("Food Processing Equipment", "Luis", "Espinoza", "lespinoza@foodprocessingequip.com", "(555) 816-3388", "Indianapolis", "IN", "46206", "Net 30", 31000m, 7400m, "18-9900112", goldTier, "Food-safe coating for processing equipment", 9),
|
||||
Comm("Security Solutions Group", "Dale", "Pratt", "dpratt@securitysolutionsgrp.com", "(555) 214-4477", "Dallas", "TX", "75202", "Net 30", 26000m, 5900m, "29-0011223", goldTier, "Security enclosures and equipment housing", 8),
|
||||
Comm("Mining Equipment Corp", "Rex", "Harmon", "rharmon@miningequip.com", "(555) 801-6622", "Salt Lake City", "UT", "84101", "Net 30", 48000m, 16400m, "40-1122334", platinumTier, "Mining and extraction equipment components", 17),
|
||||
Comm("Construction Equipment Co", "Wayne", "Briggs", "wbriggs@constructionequipco.com", "(555) 918-7733", "Oklahoma City", "OK", "73101", "Net 30", 37000m, 10100m, "51-2233445", goldTier, "Construction and earthmoving equipment parts", 12),
|
||||
Comm("Water Treatment Systems", "Irene", "Kamau", "ikamau@watertreatmentsys.com", "(555) 503-9944", "Portland", "OR", "97206", "Net 45", 44000m, 14100m, "62-3344556", platinumTier, "Municipal and industrial water treatment equipment", 18),
|
||||
Comm("Rail Equipment Systems", "Doug", "Stafford", "dstafford@railequipmentsys.com", "(555) 312-7766", "Chicago", "IL", "60609", "Net 45", 52000m, 17800m, "73-4455667", platinumTier, "Railway maintenance and rolling stock equipment", 23),
|
||||
Comm("Telecommunications Tower Co", "Maggie", "Solis", "msolis@telcotowers.com", "(555) 469-5588", "Dallas", "TX", "75203", "Net 30", 35000m, 9500m, "84-5566778", goldTier, "Cell tower hardware and mounting equipment", 10),
|
||||
Comm("Data Center Infrastructure", "Bo", "Kimura", "bkimura@datacenterinfra.com", "(555) 408-2266", "San Jose", "CA", "95113", "Net 30", 29000m, 7200m, "95-6677889", goldTier, "Server rack frames and data center equipment", 7),
|
||||
|
||||
// ─── Individual / Non-Commercial Customers (40) ───────────────────
|
||||
|
||||
Indiv("James", "Thompson", "jthompson@email.com", "(555) 111-2222", "Los Angeles", "CA", "90001", "Classic car restoration hobbyist", 6),
|
||||
Indiv("Mary", "Harris", "mharris@email.com", "(555) 222-3333", "Houston", "TX", "77001", "Patio furniture refurbishment", 4),
|
||||
Indiv("William", "Clark", "wclark@email.com", "(555) 333-4444", "Philadelphia", "PA", "19101", "Motorcycle customization", 7),
|
||||
Indiv("Elizabeth","Lewis", "elewis@email.com", "(555) 444-5555", "Phoenix", "AZ", "85001", "Garden furniture restoration", 3),
|
||||
Indiv("Richard", "Walker", "rwalker@email.com", "(555) 555-6666", "San Antonio", "TX", "78201", "Custom bike parts", 5),
|
||||
Indiv("Barbara", "Hall", "bhall@email.com", "(555) 666-7777", "San Diego", "CA", "92101", "Antique furniture hardware", 2),
|
||||
Indiv("Joseph", "Allen", "jallen@email.com", "(555) 777-8888", "Dallas", "TX", "75201", "Hot rod restoration", 8),
|
||||
Indiv("Susan", "Young", "syoung@email.com", "(555) 888-9999", "San Jose", "CA", "95101", "Home décor projects", 1),
|
||||
Indiv("Charles", "King", "cking@email.com", "(555) 999-0000", "Austin", "TX", "78701", "Vintage car parts", 5),
|
||||
Indiv("Linda", "Wright", "lwright@email.com", "(555) 000-1111", "Jacksonville", "FL", "32201", "Outdoor metalwork restoration", 3),
|
||||
Indiv("Gary", "Nelson", "gnelson@email.com", "(555) 131-4141", "Minneapolis", "MN", "55401", "Snowmobile frame and parts", 2),
|
||||
Indiv("Carol", "Evans", "carol.evans@email.com", "(555) 242-5252", "Portland", "OR", "97207", "Vintage bicycle restoration", 1),
|
||||
Indiv("Kenneth", "Scott", "kscott@email.com", "(555) 353-6363", "Baltimore", "MD", "21201", "Antique tool restoration", 3),
|
||||
Indiv("Helen", "Green", "hgreen@email.com", "(555) 464-7474", "Memphis", "TN", "38101", "Wrought iron bed frame", 4),
|
||||
Indiv("Donald", "Baker", "dbaker@email.com", "(555) 575-8585", "Louisville", "KY", "40201", "Classic truck restoration", 6),
|
||||
Indiv("Donna", "Adams", "dadams@email.com", "(555) 686-9696", "Richmond", "VA", "23218", "Outdoor light fixture set", 2),
|
||||
Indiv("Steven", "Nelson", "steven.n@email.com", "(555) 797-0707", "Columbus", "OH", "43202", "Motorcycle frame and tank", 5),
|
||||
Indiv("Patricia", "Carter", "pcarter@email.com", "(555) 808-1818", "Austin", "TX", "78703", "Patio table and chair set — 6pc", 3),
|
||||
Indiv("Mark", "Mitchell", "mmitchell@email.com", "(555) 919-2929", "Denver", "CO", "80204", "Car wheels — set of 4", 1),
|
||||
Indiv("Sandra", "Perez", "sperez@email.com", "(555) 020-3030", "El Paso", "TX", "79901", "Spiral staircase railing", 4),
|
||||
Indiv("George", "Roberts", "groberts@email.com", "(555) 141-4242", "Fort Worth", "TX", "76101", "Boat trailer frame", 3),
|
||||
Indiv("Kathleen", "Turner", "kturner@email.com", "(555) 252-5353", "Nashville", "TN", "37202", "Fireplace grate and screen", 2),
|
||||
Indiv("Eric", "Phillips", "ephillips@email.com", "(555) 363-6464", "Seattle", "WA", "98105", "Mountain bike frame", 1),
|
||||
Indiv("Sharon", "Campbell", "scampbell@email.com", "(555) 474-7575", "Boston", "MA", "02102", "Iron garden bench set", 5),
|
||||
Indiv("Larry", "Parker", "lparker@email.com", "(555) 585-8686", "Detroit", "MI", "48203", "Classic Mustang wheels and trim", 8),
|
||||
Indiv("Shirley", "Evans", "shevans@email.com", "(555) 696-9797", "Charlotte", "NC", "28202", "Deck railing system", 3),
|
||||
Indiv("Timothy", "Edwards", "tedwards@email.com", "(555) 707-0808", "Memphis", "TN", "38102", "ATV frame and fenders", 2),
|
||||
Indiv("Angela", "Collins", "acollins@email.com", "(555) 818-1919", "Las Vegas", "NV", "89101", "Casino chair legs — set of 24", 4),
|
||||
Indiv("Harold", "Stewart", "hstewart@email.com", "(555) 929-2020", "Tucson", "AZ", "85701", "Vintage pickup restoration parts", 6),
|
||||
Indiv("Pamela", "Sanchez", "psanchez@email.com", "(555) 030-3131", "Sacramento", "CA", "95815", "Wrought iron wine rack", 1),
|
||||
Indiv("Edward", "Morris", "emorris@email.com", "(555) 141-4343", "Raleigh", "NC", "27601", "Trailer hitch and receiver set", 2),
|
||||
Indiv("Frances", "Rogers", "frogers@email.com", "(555) 252-5454", "Minneapolis", "MN", "55402", "Mid-century chair frames — 4pc", 3),
|
||||
Indiv("Phillip", "Reed", "preed@email.com", "(555) 363-6565", "Omaha", "NE", "68101", "Go-kart frame and roll cage", 1),
|
||||
Indiv("Ruth", "Cook", "rcook@email.com", "(555) 474-7676", "Tulsa", "OK", "74101", "Farmhouse shelving brackets — large set", 2),
|
||||
Indiv("Andrew", "Morgan", "amorgan@email.com", "(555) 585-8787", "Atlanta", "GA", "30302", "Drift car cage and subframe", 4),
|
||||
Indiv("Mildred", "Bell", "mbell@email.com", "(555) 696-9898", "Cincinnati", "OH", "45201", "Garden gate and fence panels", 5),
|
||||
Indiv("Ralph", "Murphy", "rmurphy@email.com", "(555) 707-0909", "Fresno", "CA", "93701", "Custom motorcycle exhaust system", 3),
|
||||
Indiv("Lois", "Rivera", "lrivera@email.com", "(555) 818-1010", "Corpus Christi", "TX", "78401", "Outdoor kitchen frame and brackets", 2),
|
||||
Indiv("Roy", "Cooper", "rcooper@email.com", "(555) 929-2121", "Arlington", "TX", "76001", "Vintage tractor restoration parts", 7),
|
||||
Indiv("Vera", "Richardson","vrichardson@email.com", "(555) 030-3232", "Lexington", "KY", "40502", "Wrought iron headboard and footboard", 4),
|
||||
// Individual (ci 10–26) — residential work and hobby builds
|
||||
Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs"),
|
||||
Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts"),
|
||||
Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and bumpers"),
|
||||
Indiv("Robert", "Miller", "rmiller@email.com", "(919) 444-5555", "Cary", "NC", "27513", "Patio furniture set, railings"),
|
||||
Indiv("Jennifer", "Clark", "jclark@email.com", "(919) 555-6666", "Chapel Hill", "NC", "27514", "Bicycle frame restoration; vintage road bikes"),
|
||||
Indiv("David", "Wilson", "dwilson@email.com", "(919) 666-7777", "Wake Forest", "NC", "27587", "1969 Camaro restoration project, multiple phases"),
|
||||
Indiv("Lisa", "Anderson", "landerson@email.com", "(919) 777-8888", "Morrisville", "NC", "27560", "Home decor metal pieces, garden art"),
|
||||
Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats"),
|
||||
Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"),
|
||||
Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build"),
|
||||
Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC", "27540", "Outdoor furniture set, 6 chairs and table"),
|
||||
Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame — Candy Red"),
|
||||
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks — Flat Black"),
|
||||
Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"),
|
||||
Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate"),
|
||||
Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly"),
|
||||
Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings — Gloss Black"),
|
||||
};
|
||||
|
||||
// Add customers one at a time to handle duplicates gracefully
|
||||
foreach (var customer in customers)
|
||||
// Spread anchors evenly from Jan 1 of this year through the current month so the
|
||||
// New Customers chart shows them distributed rather than all in one bar.
|
||||
int currentMonth = now.Month;
|
||||
for (int i = 0; i < anchors.Count; i++)
|
||||
{
|
||||
int month = 1 + (i * currentMonth / anchors.Count);
|
||||
month = Math.Clamp(month, 1, currentMonth);
|
||||
int day = 3 + (i % 20);
|
||||
anchors[i].CreatedAt = new DateTime(now.Year, month, day, 8, 0, 0, DateTimeKind.Utc);
|
||||
}
|
||||
|
||||
// Insert anchors in deterministic order (commercial first, then individual).
|
||||
// This guarantees that job seeder indices 0-9 = commercial, 10-26 = individual.
|
||||
foreach (var anchor in anchors)
|
||||
{
|
||||
try
|
||||
{
|
||||
var existingCustomer = await _context.Set<Customer>()
|
||||
.IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Email == customer.Email
|
||||
&& c.CompanyId == company.Id && !c.IsDeleted);
|
||||
|
||||
if (existingCustomer != null)
|
||||
{
|
||||
skippedCount++;
|
||||
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
|
||||
warnings.Add($"⊘ Skipped: {name} — email {customer.Email} already exists");
|
||||
continue;
|
||||
}
|
||||
|
||||
await _context.Set<Customer>().AddAsync(customer);
|
||||
var dup = await _context.Set<Customer>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(c => c.Email == anchor.Email && c.CompanyId == company.Id && !c.IsDeleted);
|
||||
if (dup != null) { warnings.Add($"Skipped anchor {anchor.Email} — already exists"); continue; }
|
||||
await _context.Set<Customer>().AddAsync(anchor);
|
||||
await _context.SaveChangesAsync();
|
||||
seededCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
skippedCount++;
|
||||
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
|
||||
warnings.Add($"⊘ Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}");
|
||||
if (_context.Entry(customer).State != EntityState.Detached)
|
||||
_context.Entry(customer).State = EntityState.Detached;
|
||||
warnings.Add($"Skipped anchor {anchor.Email} — {GetFriendlyErrorMessage(ex, "customer")}");
|
||||
if (_context.Entry(anchor).State != Microsoft.EntityFrameworkCore.EntityState.Detached)
|
||||
_context.Entry(anchor).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Name and location pools for procedural fill customers ─────────────
|
||||
string[] firstNames =
|
||||
{
|
||||
"Liam", "Emma", "Noah", "Olivia", "Oliver", "Charlotte", "Elijah", "Amelia",
|
||||
"Aiden", "Harper", "Lucas", "Evelyn", "Mason", "Grace", "Ethan", "Sofia",
|
||||
"Logan", "Chloe", "Caleb", "Victoria", "Ryan", "Zoe", "Nathan", "Lily",
|
||||
"Carter", "Aria", "Dylan", "Nora", "Brandon", "Hazel",
|
||||
};
|
||||
string[] lastNames =
|
||||
{
|
||||
"Mitchell", "Reyes", "Turner", "Phillips", "Campbell", "Parker",
|
||||
"Evans", "Edwards", "Collins", "Stewart", "Fletcher", "Morris",
|
||||
"Morgan", "Bell", "Murphy", "Bailey", "Rivera", "Cooper",
|
||||
"Richardson","Cox", "Howard", "Ward", "Torres", "Peterson",
|
||||
"Gray", "Ramirez", "James", "Watson", "Brooks", "Kelly",
|
||||
};
|
||||
string[] cities = { "Raleigh", "Durham", "Cary", "Apex", "Wake Forest", "Garner",
|
||||
"Morrisville", "Fuquay-Varina", "Holly Springs", "Knightdale",
|
||||
"Wendell", "Clayton", "Zebulon", "Pittsboro", "Lillington" };
|
||||
string[] zips = { "27601", "27707", "27511", "27502", "27587", "27529",
|
||||
"27560", "27526", "27540", "27545",
|
||||
"27591", "27520", "27597", "27312", "27546" };
|
||||
string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" };
|
||||
|
||||
// Count anchors already assigned to each calendar month
|
||||
var anchorsPerMonth = new int[currentMonth + 1];
|
||||
foreach (var a in anchors)
|
||||
{
|
||||
if (a.CreatedAt.Month >= 1 && a.CreatedAt.Month <= currentMonth)
|
||||
anchorsPerMonth[a.CreatedAt.Month]++;
|
||||
}
|
||||
|
||||
// Fill each month to exactly 15 customers using procedurally generated individuals.
|
||||
// These go into db after the 27 anchors, so job seeder indices 27+ get 0 jobs (default case).
|
||||
int genIdx = 0;
|
||||
for (int month = 1; month <= currentMonth; month++)
|
||||
{
|
||||
int needed = Math.Max(0, 15 - anchorsPerMonth[month]);
|
||||
var monthStart = new DateTime(now.Year, month, 1, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
for (int slot = 0; slot < needed; slot++, genIdx++)
|
||||
{
|
||||
string fn = firstNames[genIdx % firstNames.Length];
|
||||
string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length];
|
||||
string city = cities[genIdx % cities.Length];
|
||||
string zip = zips[genIdx % zips.Length];
|
||||
string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.Length]}";
|
||||
string phone = $"({600 + genIdx % 400}) {200 + genIdx % 800:D3}-{genIdx % 9000 + 1000:D4}";
|
||||
int day = needed > 1 ? 1 + (slot * 27 / needed) : 14;
|
||||
|
||||
var gen = new Customer
|
||||
{
|
||||
ContactFirstName = fn,
|
||||
ContactLastName = ln,
|
||||
Email = email,
|
||||
Phone = phone,
|
||||
City = city,
|
||||
State = "NC",
|
||||
ZipCode = zip,
|
||||
IsCommercial = false,
|
||||
PaymentTerms = "Due on receipt",
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = monthStart.AddDays(day),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
await _context.Set<Customer>().AddAsync(gen);
|
||||
await _context.SaveChangesAsync();
|
||||
seededCount++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
warnings.Add($"Skipped generated customer {email} — {GetFriendlyErrorMessage(ex, "customer")}");
|
||||
if (_context.Entry(gen).State != Microsoft.EntityFrameworkCore.EntityState.Detached)
|
||||
_context.Entry(gen).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ public partial class SeedDataService
|
||||
{
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Batch Powder Coating Oven #1",
|
||||
EquipmentName = "Main Batch Oven",
|
||||
EquipmentNumber = $"{company.CompanyCode}-OVN-001",
|
||||
EquipmentType = "Oven",
|
||||
Manufacturer = "Reliant Finishing Systems",
|
||||
@@ -78,7 +78,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Batch Powder Coating Oven #2",
|
||||
EquipmentName = "Small Batch Oven",
|
||||
EquipmentNumber = $"{company.CompanyCode}-OVN-002",
|
||||
EquipmentType = "Oven",
|
||||
Manufacturer = "Reliant Finishing Systems",
|
||||
@@ -99,7 +99,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Automated Powder Coating Booth #1",
|
||||
EquipmentName = "Powder Coating Booth",
|
||||
EquipmentNumber = $"{company.CompanyCode}-BOOTH-001",
|
||||
EquipmentType = "Spray Booth",
|
||||
Manufacturer = "Nordson Corporation",
|
||||
@@ -120,7 +120,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Manual Powder Coating Booth #2",
|
||||
EquipmentName = "Manual Powder Booth",
|
||||
EquipmentNumber = $"{company.CompanyCode}-BOOTH-002",
|
||||
EquipmentType = "Spray Booth",
|
||||
Manufacturer = "Columbia Coatings",
|
||||
@@ -162,7 +162,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Media Blast Room",
|
||||
EquipmentName = "Pressure Pot Blaster",
|
||||
EquipmentNumber = $"{company.CompanyCode}-BLAST-002",
|
||||
EquipmentType = "Sandblaster",
|
||||
Manufacturer = "Clemco Industries",
|
||||
@@ -183,7 +183,7 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Rotary Screw Air Compressor",
|
||||
EquipmentName = "Air Compressor",
|
||||
EquipmentNumber = $"{company.CompanyCode}-COMP-001",
|
||||
EquipmentType = "Compressor",
|
||||
Manufacturer = "Atlas Copco",
|
||||
@@ -204,28 +204,28 @@ public partial class SeedDataService
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Overhead Conveyor System",
|
||||
EquipmentNumber = $"{company.CompanyCode}-CONV-001",
|
||||
EquipmentType = "Conveyor",
|
||||
Manufacturer = "Pacline Conveyors",
|
||||
Model = "PAC-500 Overhead",
|
||||
EquipmentName = "Forklift",
|
||||
EquipmentNumber = $"{company.CompanyCode}-FORK-001",
|
||||
EquipmentType = "Forklift",
|
||||
Manufacturer = "Toyota",
|
||||
Model = "8FGCU25",
|
||||
SerialNumber = "PAC50034521",
|
||||
PurchaseDate = DateTime.UtcNow.AddYears(-4),
|
||||
PurchasePrice = 52000m,
|
||||
PurchasePrice = 28000m,
|
||||
WarrantyExpiration = DateTime.UtcNow.AddYears(-2),
|
||||
Status = EquipmentStatus.Operational,
|
||||
Location = "Main Production Line",
|
||||
Location = "Loading Area",
|
||||
RecommendedMaintenanceIntervalDays = 180,
|
||||
LastMaintenanceDate = DateTime.UtcNow.AddDays(-120),
|
||||
NextScheduledMaintenance = DateTime.UtcNow.AddDays(60),
|
||||
Notes = "500 lb capacity overhead conveyor with power and free sections",
|
||||
LastMaintenanceDate = DateTime.UtcNow.AddDays(-90),
|
||||
NextScheduledMaintenance = DateTime.UtcNow.AddDays(90),
|
||||
Notes = "5,000 lb capacity propane forklift — used for loading/unloading customer parts",
|
||||
IsActive = true,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = DateTime.UtcNow.AddYears(-4)
|
||||
},
|
||||
new Equipment
|
||||
{
|
||||
EquipmentName = "Parts Washer System",
|
||||
EquipmentName = "Wash Station",
|
||||
EquipmentNumber = $"{company.CompanyCode}-WASH-001",
|
||||
EquipmentType = "Washer",
|
||||
Manufacturer = "Better Engineering",
|
||||
|
||||
+31
-18
@@ -54,17 +54,25 @@ public partial class SeedDataService
|
||||
|
||||
if (items.Count == 0) return 0;
|
||||
|
||||
// Load completed/delivered jobs to generate usage transactions against
|
||||
var completedJobs = await _context.Set<Job>()
|
||||
// Two-query approach: resolve status IDs first to avoid Include() navigation
|
||||
// returning null when global query filters interact with IgnoreQueryFilters().
|
||||
var completedStatusIds = await _context.Set<JobStatusLookup>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||||
&& (j.JobStatus.StatusCode == "COMPLETED"
|
||||
|| j.JobStatus.StatusCode == "READY_FOR_PICKUP"
|
||||
|| j.JobStatus.StatusCode == "DELIVERED"))
|
||||
.Include(j => j.JobStatus)
|
||||
.OrderBy(j => j.Id)
|
||||
.Where(s => s.CompanyId == company.Id
|
||||
&& new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }.Contains(s.StatusCode))
|
||||
.Select(s => s.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var completedJobs = completedStatusIds.Count == 0
|
||||
? new List<Job>()
|
||||
: await _context.Set<Job>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
|
||||
&& completedStatusIds.Contains(j.JobStatusId))
|
||||
.Include(j => j.JobItems)
|
||||
.OrderBy(j => j.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var seeded = 0;
|
||||
var txns = new List<InventoryTransaction>();
|
||||
@@ -90,14 +98,18 @@ public partial class SeedDataService
|
||||
});
|
||||
}
|
||||
|
||||
// ── Purchase transactions — 3 months of restocks ──────────────────────
|
||||
// Simulate monthly powder purchases for top items
|
||||
var powderItems = items.Take(8).ToList(); // focus on powder coat items
|
||||
|
||||
foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] {
|
||||
(85, 1.2m), (55, 1.0m), (25, 0.9m) })
|
||||
// ── Purchase transactions — 12 months of monthly restocks ────────────
|
||||
var powderItems = items.Take(8).ToList();
|
||||
// Quantities vary slightly month to month to give the inventory chart a natural shape
|
||||
var purchaseOffsets = new (int daysAgo, decimal mult)[]
|
||||
{
|
||||
foreach (var item in powderItems.Take(4)) // 4 items per purchase cycle
|
||||
(365, 0.80m), (335, 0.85m), (305, 0.90m), (275, 0.95m),
|
||||
(245, 1.00m), (215, 1.05m), (185, 1.10m), (155, 1.10m),
|
||||
(125, 1.00m), ( 95, 1.10m), ( 65, 1.00m), ( 35, 0.95m)
|
||||
};
|
||||
foreach (var (offset, qtyMult) in purchaseOffsets)
|
||||
{
|
||||
foreach (var item in powderItems.Take(4))
|
||||
{
|
||||
var qty = Math.Round(25m * qtyMult, 0);
|
||||
txns.Add(new InventoryTransaction
|
||||
@@ -144,9 +156,10 @@ public partial class SeedDataService
|
||||
|
||||
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
|
||||
{
|
||||
// Completed date spread: most within the last 60 days
|
||||
var daysAgo = 10 + (idx % 55);
|
||||
var usageDate = now.AddDays(-daysAgo);
|
||||
// Use the job's actual completion date so powder usage history spans the same
|
||||
// 12-month window as jobs, giving the Powder Usage report non-trivial data in
|
||||
// every month rather than clustering everything in the last 60 days.
|
||||
var usageDate = (job.CompletedDate ?? job.ScheduledDate ?? now.AddDays(-30)).Date;
|
||||
|
||||
// Pick a color-matched powder item (or rotate)
|
||||
var firstItem = job.JobItems?.FirstOrDefault();
|
||||
|
||||
@@ -183,6 +183,23 @@ public partial class SeedDataService
|
||||
}
|
||||
}
|
||||
|
||||
// ── Months −12 through −4: 8 paid invoices per month ────────────────
|
||||
// 8 × avg ~$650 = ~$5,200/month collected revenue, which exceeds the seeded
|
||||
// ~$4,200/month in operating expenses, so the P&L chart shows a consistent profit.
|
||||
// Payment methods and tax rates alternate for variety in the ledger.
|
||||
var histMethods = new[] { PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.CreditDebitCard, PaymentMethod.Cash };
|
||||
for (int monthBack = 12; monthBack >= 4; monthBack--)
|
||||
{
|
||||
for (int inv = 0; inv < 8; inv++)
|
||||
{
|
||||
var daysAgo = monthBack * 30 + 25 - (inv * 3);
|
||||
var taxPct = (monthBack + inv) % 2 == 0 ? 7.5m : 0m;
|
||||
var method = histMethods[(monthBack * 8 + inv) % histMethods.Length];
|
||||
var chkRef = method == PaymentMethod.Check ? $"CHK-{9000 + monthBack * 8 + inv:D4}" : null;
|
||||
await Inv(InvoiceStatus.Paid, daysAgo, 30, taxPct, "Net 30", "Thank you for your business!", method, chkRef);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Month −3 (6 paid) ─────────────────────────────────────────────────
|
||||
await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 84, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1041");
|
||||
@@ -201,24 +218,35 @@ public partial class SeedDataService
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 40, 30, 0m, "Net 30", "50% deposit received — balance due.", PaymentMethod.Check, "CHK-1053");
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 37, 30, 7.5m, "Net 30", "Deposit on file — balance due on pickup.", PaymentMethod.BankTransferACH);
|
||||
|
||||
// ── Month −1 (5 paid + 2 partial + 2 sent) ───────────────────────────
|
||||
await Inv(InvoiceStatus.Paid, 32, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 28, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1056");
|
||||
await Inv(InvoiceStatus.Paid, 24, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash);
|
||||
await Inv(InvoiceStatus.Paid, 20, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
|
||||
// ── Month −1 (7 paid + 2 partial + 2 sent) ───────────────────────────
|
||||
// 7 paid × avg ~$650 + 2 partial × 50% × avg ~$650 ≈ $5,200 collected — above expenses.
|
||||
await Inv(InvoiceStatus.Paid, 35, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 32, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1054");
|
||||
await Inv(InvoiceStatus.Paid, 28, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 24, 14, 7.5m, "Net 14", "Thank you!", PaymentMethod.Cash);
|
||||
await Inv(InvoiceStatus.Paid, 20, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Paid, 18, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
|
||||
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1058");
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 14, 30, 7.5m, "Net 30", "50% deposit received.", PaymentMethod.Check, "CHK-1060");
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 11, 30, 0m, "Net 30", "50% deposit — balance due on completion.", PaymentMethod.BankTransferACH);
|
||||
await Inv(InvoiceStatus.Sent, 9, 30, 7.5m, "Net 30", "Payment due within 30 days.");
|
||||
await Inv(InvoiceStatus.Sent, 6, 30, 0m, "Net 30", "Payment due within 30 days.");
|
||||
|
||||
// ── Current month (1 overdue + 2 sent + 1 draft) ─────────────────────
|
||||
// Overdue: created 35 days ago on Net 14 terms → 21 days past due
|
||||
// Overdue: created 35 days ago on Net 14 terms → 21 days past due (1–30 bucket)
|
||||
await Inv(InvoiceStatus.Sent, 35, 14, 7.5m, "Net 14", "PAST DUE — please remit payment immediately.");
|
||||
await Inv(InvoiceStatus.Sent, 4, 30, 0m, "Net 30", "Payment due within 30 days.");
|
||||
await Inv(InvoiceStatus.Sent, 2, 30, 7.5m, "Net 30", "Payment due within 30 days.");
|
||||
await Inv(InvoiceStatus.Draft, 1, 30, 0m, "Net 30", null);
|
||||
|
||||
// ── AR Aging demo invoices — populate all four overdue buckets ────────
|
||||
// 31–60 day bucket: issued 55 days ago, Net 10 → 45 days past due
|
||||
await Inv(InvoiceStatus.Sent, 55, 10, 0m, "Net 10", "PAST DUE 45 days — second notice sent.");
|
||||
// 61–90 day bucket: issued 80 days ago, Net 10 → 70 days past due
|
||||
await Inv(InvoiceStatus.Sent, 80, 10, 7.5m, "Net 10", "PAST DUE 70 days — final notice. Collections pending.");
|
||||
// 90+ day bucket: issued 120 days ago, Net 14 → 106 days past due
|
||||
await Inv(InvoiceStatus.PartiallyPaid, 120, 14, 0m, "Net 14", "PAST DUE 106 days — partial payment received, balance outstanding.");
|
||||
|
||||
return seeded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,41 +7,30 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 50 powder coating jobs that collectively demonstrate all 16 job statuses,
|
||||
/// realistic date progressions, varied priorities, and quote linkage for the first 25 jobs.
|
||||
/// Seeds 50 powder coating jobs distributed across all 16 statuses with a realistic
|
||||
/// weighted distribution (Delivered most common, exactly one Cancelled, one OnHold)
|
||||
/// and a shuffled visit order so jobs from different customers are interleaved naturally
|
||||
/// rather than appearing as per-customer blocks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotency: returns 0 immediately if any non-deleted jobs already exist for this company.
|
||||
/// Per-customer job counts and price ranges are defined by <c>CustomerProfile(ci)</c>
|
||||
/// where <c>ci</c> is the customer's position in the Id-ascending list seeded by
|
||||
/// <c>SeedCustomersAsync</c> (0 = Carolina Fabrication, the largest account).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The method depends on job-status and job-priority lookup rows (populated earlier in the
|
||||
/// seed sequence), and on at least one customer record. It returns 0 if any of these
|
||||
/// dependencies are missing so the overall seed degrades gracefully.
|
||||
/// A shuffled <c>visitSchedule</c> (fixed seed 42) drives the outer loop so that
|
||||
/// Carolina Fabrication, Apex Motorsports, and individual customers appear
|
||||
/// interleaved in creation-date order rather than in consecutive customer blocks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Job numbers follow the production format <c>JOB-YYMM-####</c>. The seeder scans
|
||||
/// existing numbers with the current month prefix and starts its sequence above the current
|
||||
/// maximum so demo jobs never collide with real jobs created in the same calendar month.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The first 25 jobs are linked to approved quotes (loaded from the previously seeded
|
||||
/// quotes). When a match is found the job inherits the quote's customer, description,
|
||||
/// quoted price, and customer PO — matching the production quote-to-job conversion path.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Date logic groups jobs into three buckets: early-stage (future scheduled date),
|
||||
/// in-progress (past start date, no completion), and completed/terminal (both started
|
||||
/// and completed dates in the past). This ensures the dashboard pipeline and calendar
|
||||
/// views display a realistic spread rather than all jobs sharing the same date.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The <c>IgnoreQueryFilters()</c> call on the existence check ensures that soft-deleted
|
||||
/// leftover jobs from a previous seed run are detected and do not cause duplicate inserts.
|
||||
/// Status pool (fixed seed 99): Delivered ×10, Completed ×8,
|
||||
/// ReadyForPickup ×5, then decreasing counts for in-progress stages, with
|
||||
/// exactly one Cancelled and one OnHold.
|
||||
/// Priority pool (fixed seed 77): Normal 76 %, High 12 %, Urgent 8 %,
|
||||
/// Rush 4 % — rush is genuinely rare.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed jobs for.</param>
|
||||
/// <returns>Number of jobs inserted, or 0 if already seeded or dependencies are missing.</returns>
|
||||
private async Task<int> SeedJobsAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<Job>()
|
||||
@@ -73,11 +62,11 @@ public partial class SeedDataService
|
||||
if (customers.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Grab approved quotes to link to jobs
|
||||
var approvedQuotes = await _context.Set<Quote>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
|
||||
.OrderBy(q => q.Id)
|
||||
.OrderBy(q => q.CustomerId)
|
||||
.ThenBy(q => q.Id)
|
||||
.ToListAsync();
|
||||
|
||||
var shopUsers = await _context.Set<ApplicationUser>()
|
||||
@@ -86,7 +75,6 @@ public partial class SeedDataService
|
||||
.ToListAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var prefix = $"JOB-{now:yy}{now.Month:D2}-";
|
||||
var existing = await _context.Set<Job>()
|
||||
.IgnoreQueryFilters()
|
||||
@@ -98,129 +86,240 @@ public partial class SeedDataService
|
||||
if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x;
|
||||
var seq = maxNum + 1;
|
||||
|
||||
// ── Status plan (50 jobs, covering all 16 statuses) ──────────────────
|
||||
// Active pipeline: PENDING(4) QUOTED(3) APPROVED(4) IN_PREPARATION(4)
|
||||
// SANDBLASTING(4) MASKING_TAPING(3) CLEANING(3) IN_OVEN(3)
|
||||
// COATING(4) CURING(3) QUALITY_CHECK(3) COMPLETED(5)
|
||||
// READY_FOR_PICKUP(4) DELIVERED(3) ON_HOLD(2) CANCELLED(2)
|
||||
//
|
||||
// Maps job index to a status code, distributing all 16 statuses across 50 jobs.
|
||||
// ON_HOLD and CANCELLED are placed last (indices 48–49) because they are terminal
|
||||
// side-branches that affect date logic and status history traversal differently.
|
||||
static string StatusFor(int i) => i switch
|
||||
// ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
|
||||
// Indices match the customer insertion order from SeedCustomersAsync (ascending Id):
|
||||
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
|
||||
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
|
||||
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
|
||||
// 10–26 = individual residential customers
|
||||
static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch
|
||||
{
|
||||
< 4 => "PENDING",
|
||||
< 7 => "QUOTED",
|
||||
< 11 => "APPROVED",
|
||||
< 15 => "IN_PREPARATION",
|
||||
< 19 => "SANDBLASTING",
|
||||
< 22 => "MASKING_TAPING",
|
||||
< 25 => "CLEANING",
|
||||
< 28 => "IN_OVEN",
|
||||
< 32 => "COATING",
|
||||
< 35 => "CURING",
|
||||
< 38 => "QUALITY_CHECK",
|
||||
< 43 => "COMPLETED",
|
||||
< 47 => "READY_FOR_PICKUP",
|
||||
< 48 => "DELIVERED",
|
||||
< 49 => "ON_HOLD",
|
||||
_ => "CANCELLED"
|
||||
0 => (7, 800m, 2500m), // Carolina Fabrication — largest account
|
||||
1 => (6, 400m, 1500m), // Apex Motorsports
|
||||
2 => (5, 350m, 1200m), // Triangle Offroad
|
||||
3 => (4, 250m, 800m), // Smith Welding
|
||||
4 => (4, 300m, 900m), // Raleigh Architectural Metals
|
||||
5 => (3, 200m, 600m), // East Coast Powderworks
|
||||
6 => (3, 150m, 450m), // Piedmont Metal Works
|
||||
7 => (2, 200m, 500m), // Cary Industrial Solutions
|
||||
8 => (2, 350m, 900m), // Durham Tech Equipment
|
||||
9 => (3, 400m, 1500m), // Wake County Fleet Services
|
||||
10 => (2, 75m, 250m), // John Davis
|
||||
11 => (1, 150m, 350m), // Sarah Jenkins
|
||||
12 => (1, 200m, 400m), // Mike Thompson
|
||||
13 => (2, 100m, 300m), // Robert Miller
|
||||
14 => (0, 0m, 0m), // Jennifer Clark — prospect only
|
||||
15 => (1, 100m, 250m), // David Wilson
|
||||
16 => (0, 0m, 0m), // Lisa Anderson — prospect only
|
||||
17 => (1, 150m, 300m), // Thomas Harris
|
||||
18 => (0, 0m, 0m), // Karen White — no jobs yet
|
||||
19 => (1, 250m, 500m), // James Taylor
|
||||
20 => (0, 0m, 0m), // Michelle Brown — no jobs yet
|
||||
21 => (1, 100m, 250m), // Chris Lee
|
||||
22 => (0, 0m, 0m), // Amanda Garcia — no jobs yet
|
||||
23 => (1, 150m, 350m), // Kevin Martinez
|
||||
24 => (0, 0m, 0m), // Nancy Rodriguez — no jobs yet
|
||||
25 => (0, 0m, 0m), // Brian Hall — no jobs yet
|
||||
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
|
||||
};
|
||||
|
||||
// Maps job index modulo 10 to a priority code. RUSH and URGENT are intentionally
|
||||
// over-represented (4 of 10) relative to production averages so the priority colour
|
||||
// badges and rush-fee logic are clearly visible in demo data.
|
||||
static string PriorityFor(int i) => (i % 10) switch
|
||||
// ── Status pool: realistic shop distribution (total = 50) ──────────────
|
||||
// Delivered and Completed dominate; exactly one Cancelled and one OnHold.
|
||||
var statusPool = new List<string>();
|
||||
foreach (var (code, count) in new (string Code, int Count)[]
|
||||
{
|
||||
0 => "RUSH",
|
||||
1 => "RUSH",
|
||||
2 => "URGENT",
|
||||
3 => "URGENT",
|
||||
4 => "HIGH",
|
||||
5 => "HIGH",
|
||||
6 => "HIGH",
|
||||
_ => "NORMAL"
|
||||
};
|
||||
("DELIVERED", 10),
|
||||
("COMPLETED", 8),
|
||||
("READY_FOR_PICKUP", 5),
|
||||
("IN_PREPARATION", 4),
|
||||
("SANDBLASTING", 4),
|
||||
("COATING", 3),
|
||||
("QUALITY_CHECK", 3),
|
||||
("CURING", 3),
|
||||
("MASKING_TAPING", 2),
|
||||
("IN_OVEN", 2),
|
||||
("CLEANING", 1),
|
||||
("PENDING", 1),
|
||||
("QUOTED", 1),
|
||||
("APPROVED", 1),
|
||||
("ON_HOLD", 1),
|
||||
("CANCELLED", 1),
|
||||
})
|
||||
{
|
||||
for (int k = 0; k < count; k++) statusPool.Add(code);
|
||||
}
|
||||
// Fisher-Yates shuffle with a fixed seed so resets produce the same distribution
|
||||
var statusRng = new Random(99);
|
||||
for (int k = statusPool.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = statusRng.Next(k + 1);
|
||||
(statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]);
|
||||
}
|
||||
|
||||
// Returns description, finish color, prep flags, and estimated minutes for a job item.
|
||||
// Indexed by (i * 3 + j) % 15 so that item variety cycles independently of the job index,
|
||||
// preventing every job from having the same first item.
|
||||
// ── Priority pool: realistic distribution (total = 50) ─────────────────
|
||||
// Rush jobs are genuinely rare; most work is Normal priority.
|
||||
var priorityPool = new List<string>();
|
||||
foreach (var (code, count) in new (string Code, int Count)[]
|
||||
{
|
||||
("NORMAL", 38),
|
||||
("HIGH", 6),
|
||||
("URGENT", 4),
|
||||
("RUSH", 2),
|
||||
})
|
||||
{
|
||||
for (int k = 0; k < count; k++) priorityPool.Add(code);
|
||||
}
|
||||
var priorityRng = new Random(77);
|
||||
for (int k = priorityPool.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = priorityRng.Next(k + 1);
|
||||
(priorityPool[k], priorityPool[swap]) = (priorityPool[swap], priorityPool[k]);
|
||||
}
|
||||
|
||||
// ── Customer visit schedule: interleave commercial (ci 0–9) and individual (ci 10+) ──
|
||||
// A plain Fisher-Yates on the full list clusters commercial entries because they
|
||||
// outnumber individual ones 4:1; splitting into two pools and distributing
|
||||
// individual jobs evenly throughout ensures the two types never appear in blocks.
|
||||
var commercialVisits = new List<int>();
|
||||
var individualVisits = new List<int>();
|
||||
for (int ci = 0; ci < customers.Count; ci++)
|
||||
{
|
||||
var (numJobs, _, _) = CustomerProfile(ci);
|
||||
for (int j = 0; j < numJobs; j++)
|
||||
(ci < 10 ? commercialVisits : individualVisits).Add(ci);
|
||||
}
|
||||
var rngC = new Random(42);
|
||||
for (int k = commercialVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngC.Next(k + 1);
|
||||
(commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]);
|
||||
}
|
||||
var rngI = new Random(17);
|
||||
for (int k = individualVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngI.Next(k + 1);
|
||||
(individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]);
|
||||
}
|
||||
// Distribute individual visits at evenly-spaced positions throughout the commercial list
|
||||
var visitSchedule = new List<int>(commercialVisits.Count + individualVisits.Count);
|
||||
double indStride = individualVisits.Count > 0
|
||||
? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0)
|
||||
: double.MaxValue;
|
||||
int indInsertIdx = 0;
|
||||
for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++)
|
||||
{
|
||||
while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
visitSchedule.Add(commercialVisits[comIdx]);
|
||||
}
|
||||
while (indInsertIdx < individualVisits.Count)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
|
||||
// Job item descriptions and specs — 15-item pool cycling via (visitIdx*3 + itemIdx) % 15.
|
||||
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
|
||||
((i * 3 + j) % 15) switch
|
||||
{
|
||||
0 => ("18\" Aluminum Wheels — Matte Black", "Matte Black", true, false, 45),
|
||||
1 => ("17\" Steel Wheels — Gloss White", "Gloss White", false, false, 30),
|
||||
2 => ("Valve Covers — Wrinkle Red", "Wrinkle Red", true, true, 40),
|
||||
3 => ("Motorcycle Frame — Flat Black", "Flat Black", true, false, 90),
|
||||
4 => ("Steel Shelving Units", "Textured Gray", true, false, 55),
|
||||
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
|
||||
6 => ("Aluminum Window Frames", "Satin Bronze", false, true, 50),
|
||||
7 => ("Steel Handrail — 40 ft run", "Gloss Black", true, false, 120),
|
||||
8 => ("Wrought Iron Gate", "Hammered Black", true, false, 180),
|
||||
9 => ("Brake Calipers — Gloss Yellow", "Gloss Yellow", false, true, 35),
|
||||
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
|
||||
11 => ("Bicycle Frame — Candy Blue", "Candy Blue", true, true, 60),
|
||||
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
|
||||
13 => ("Patio Furniture Set", "Textured Beige", false, false, 50),
|
||||
_ => ("Custom Steel Parts — Batch", "Matte Gray", true, false, 40)
|
||||
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45),
|
||||
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30),
|
||||
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60),
|
||||
3 => ("Motorcycle Frame", "Matte Black", true, false, 90),
|
||||
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55),
|
||||
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
|
||||
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50),
|
||||
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120),
|
||||
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180),
|
||||
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35),
|
||||
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
|
||||
11 => ("Bicycle Frame", "Candy Red", true, true, 60),
|
||||
12 => ("Compressor Tank", "Safety Orange", true, false, 45),
|
||||
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50),
|
||||
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40)
|
||||
};
|
||||
|
||||
var jobs = new List<Job>();
|
||||
var quoteIdx = 0;
|
||||
var jobs = new List<Job>();
|
||||
var quoteIdx = 0;
|
||||
var jobsByCustomer = new int[customers.Count]; // within-customer job counter per ci
|
||||
var jobIdx = 0; // global counter for misc modulos
|
||||
var inProgressCount = 0; // caps "Carried Over" card to 2 jobs
|
||||
var completedJobCount = 0; // drives linear date spread over 12 months
|
||||
|
||||
for (int i = 0; i < 50; i++)
|
||||
for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++, jobIdx++, seq++)
|
||||
{
|
||||
var statusCode = StatusFor(i);
|
||||
var priorityCode = PriorityFor(i);
|
||||
var customer = customers[i % customers.Count];
|
||||
var ci = visitSchedule[visitIdx];
|
||||
var customer = customers[ci];
|
||||
var j = jobsByCustomer[ci]++; // within-customer job index
|
||||
var (_, minVal, maxVal) = CustomerProfile(ci);
|
||||
|
||||
// Link an approved quote to the first 25 in-progress/active jobs
|
||||
var statusCode = statusPool[visitIdx];
|
||||
var priorityCode = priorityPool[visitIdx];
|
||||
|
||||
// Try to link the first available approved quote for this customer
|
||||
Quote? linkedQuote = null;
|
||||
if (i < 25 && quoteIdx < approvedQuotes.Count)
|
||||
for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++)
|
||||
{
|
||||
// Only link if the quote's customer matches OR if customers align by index
|
||||
linkedQuote = approvedQuotes[quoteIdx++];
|
||||
customer = customers.FirstOrDefault(c => c.Id == linkedQuote.CustomerId) ?? customer;
|
||||
if (approvedQuotes[qi].CustomerId == customer.Id)
|
||||
{
|
||||
linkedQuote = approvedQuotes[qi];
|
||||
quoteIdx = qi + 1;
|
||||
break;
|
||||
}
|
||||
// Every 4th job, forcibly consume the next available approved quote
|
||||
if (quoteIdx % 4 == 0 && qi == quoteIdx)
|
||||
{
|
||||
linkedQuote = approvedQuotes[qi];
|
||||
quoteIdx++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Date logic — creation spread from -21 days to today
|
||||
// Scheduled: future for early statuses, past for completed ones
|
||||
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED";
|
||||
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
|
||||
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
|
||||
var isEarly = statusCode is "PENDING" or "QUOTED" or "APPROVED";
|
||||
// Date logic: completed jobs furthest back, ready-for-pickup recent past,
|
||||
// in-progress spread forward, pending/quoted in the future.
|
||||
var isCompleted = statusCode is "COMPLETED" or "DELIVERED" or "CANCELLED";
|
||||
var isReadyForPickup = statusCode == "READY_FOR_PICKUP";
|
||||
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
|
||||
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
|
||||
|
||||
int daysAgo = isCompleted ? 14 + (i % 7)
|
||||
: isInProgress ? 5 + (i % 7)
|
||||
: 0 + (i % 5);
|
||||
var createdDate = now.AddDays(-daysAgo);
|
||||
var scheduledDate = isCompleted ? createdDate.AddDays(2)
|
||||
: isInProgress ? now.AddDays(-(i % 3))
|
||||
: now.AddDays(2 + (i % 10));
|
||||
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
|
||||
var dueDate = scheduledDate.AddDays(rushDays);
|
||||
var startedDate = (!isEarly) ? scheduledDate : (DateTime?)null;
|
||||
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
|
||||
if (isInProgress) inProgressCount++;
|
||||
if (isCompleted) completedJobCount++;
|
||||
|
||||
var assignedUserId = shopUsers.Count > 0 ? shopUsers[i % shopUsers.Count].Id : null;
|
||||
// Completed jobs spread linearly ~1–12 months back for chart coverage.
|
||||
// Ready-for-pickup: job finished recently, just waiting on customer.
|
||||
// In-progress: first 3 are genuinely past-due ("Carried Over"); rest spread into future.
|
||||
int daysAgo = isCompleted ? 30 + (completedJobCount - 1) * 14
|
||||
: isReadyForPickup ? 5 + (visitIdx % 8)
|
||||
: isInProgress ? 10 + (visitIdx % 40)
|
||||
: 2 + (visitIdx % 15);
|
||||
var createdDate = now.AddDays(-daysAgo);
|
||||
|
||||
var itemCount = 1 + (i % 3);
|
||||
var items = new List<JobItem>();
|
||||
var scheduledDate = isCompleted ? createdDate.AddDays(3 + (visitIdx % 5))
|
||||
: isReadyForPickup ? now.AddDays(visitIdx % 5)
|
||||
: isInProgress ? now.AddDays(inProgressCount <= 3 ? -(4 - inProgressCount) : inProgressCount - 3)
|
||||
: now.AddDays(3 + (visitIdx % 12));
|
||||
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
|
||||
var dueDate = scheduledDate.AddDays(rushDays);
|
||||
var startedDate = isCompleted || isReadyForPickup || isInProgress ? (DateTime?)scheduledDate : null;
|
||||
var completedDate = isCompleted || isReadyForPickup ? scheduledDate.AddDays(1) : (DateTime?)null;
|
||||
|
||||
for (int j = 0; j < itemCount; j++)
|
||||
// Per-customer value targeting: deterministic variance within the customer's price range
|
||||
var range = maxVal - minVal;
|
||||
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
|
||||
var itemCount = 1 + (visitIdx % 3);
|
||||
var items = new List<JobItem>();
|
||||
|
||||
for (int k = 0; k < itemCount; k++)
|
||||
{
|
||||
var (desc, color, sand, mask, mins) = ItemSpec(i, j);
|
||||
var qty = 1 + (j % 3);
|
||||
var unitPrice = linkedQuote != null && j == 0
|
||||
? Math.Round((linkedQuote.Total / itemCount), 2)
|
||||
: Math.Round(75m + (i % 8) * 12.5m + j * 15m, 2);
|
||||
var (desc, color, sand, mask, mins) = ItemSpec(visitIdx, k);
|
||||
var qty = 1 + (k % 3);
|
||||
var unitPrice = linkedQuote != null && k == 0
|
||||
? Math.Round(linkedQuote.Total / itemCount, 2)
|
||||
: Math.Round(targetValue / itemCount / qty, 2);
|
||||
|
||||
items.Add(new JobItem
|
||||
{
|
||||
Description = desc,
|
||||
Quantity = qty,
|
||||
ColorName = color,
|
||||
SurfaceAreaSqFt = 10m + j * 3.5m,
|
||||
SurfaceAreaSqFt = 10m + k * 3.5m,
|
||||
UnitPrice = unitPrice,
|
||||
TotalPrice = unitPrice * qty,
|
||||
LaborCost = Math.Round(unitPrice * qty * 0.35m, 2),
|
||||
@@ -240,7 +339,7 @@ public partial class SeedDataService
|
||||
JobNumber = $"{prefix}{seq:D4}",
|
||||
CustomerId = customer.Id,
|
||||
QuoteId = linkedQuote?.Id,
|
||||
AssignedUserId = assignedUserId,
|
||||
AssignedUserId = shopUsers.Count > 0 ? shopUsers[visitIdx % shopUsers.Count].Id : null,
|
||||
Description = linkedQuote?.Description
|
||||
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
|
||||
JobStatusId = jobStatuses[statusCode],
|
||||
@@ -252,18 +351,20 @@ public partial class SeedDataService
|
||||
QuotedPrice = quotedPrice,
|
||||
FinalPrice = finalPrice,
|
||||
IsRushJob = priorityCode == "RUSH",
|
||||
CustomerPO = linkedQuote?.CustomerPO ?? (i % 3 == 0 ? $"PO-{40000 + i}" : null),
|
||||
SpecialInstructions = i % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
||||
i % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
||||
InternalNotes = i % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
||||
RequiresCustomerApproval = i % 5 == 0,
|
||||
IsCustomerApproved = i % 5 != 0 || !isEarly,
|
||||
CustomerPO = customer.IsCommercial && visitIdx % 3 == 0 ? $"PO-{40000 + visitIdx}" : null,
|
||||
SpecialInstructions = visitIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
|
||||
visitIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
|
||||
InternalNotes = visitIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
|
||||
RequiresCustomerApproval = visitIdx % 5 == 0,
|
||||
IsCustomerApproved = visitIdx % 5 != 0 || !isInProgress,
|
||||
JobItems = items,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = createdDate
|
||||
CreatedAt = createdDate,
|
||||
// Set UpdatedAt to the historical event date so analytics charts group into the
|
||||
// correct month. The EF interceptor only stamps UpdatedAt on Modified saves,
|
||||
// leaving it null for seeded entities, which the analytics filter treats as excluded.
|
||||
UpdatedAt = completedDate ?? (isInProgress ? scheduledDate : (DateTime?)null) ?? createdDate
|
||||
});
|
||||
|
||||
seq++;
|
||||
}
|
||||
|
||||
await _context.Set<Job>().AddRangeAsync(jobs);
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds maintenance records for all seeded equipment: historical completed records,
|
||||
/// upcoming scheduled records, and one overdue record for the Pressure Pot so the
|
||||
/// Equipment Maintenance report always has meaningful data.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each piece of equipment gets 2-3 completed historical maintenance records (up to
|
||||
/// 12 months back) plus one upcoming scheduled record. The Pressure Pot additionally
|
||||
/// has one overdue record (past due date, still Scheduled) to populate the overdue
|
||||
/// indicator on the Equipment page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Labor and parts costs are realistic for shop equipment maintenance, giving the
|
||||
/// Equipment Maintenance Cost report non-trivial totals from day one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotency: bails early if any maintenance records already exist for this company's equipment.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<int> SeedMaintenanceRecordsAsync(Company company)
|
||||
{
|
||||
var equipmentIds = await _context.Set<Equipment>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => e.CompanyId == company.Id && !e.IsDeleted && SeededEquipmentSerials.Contains(e.SerialNumber))
|
||||
.Select(e => e.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (equipmentIds.Count == 0) return 0;
|
||||
|
||||
var existingCount = await _context.Set<MaintenanceRecord>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(m => equipmentIds.Contains(m.EquipmentId));
|
||||
if (existingCount > 0) return 0;
|
||||
|
||||
var equipment = await _context.Set<Equipment>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(e => equipmentIds.Contains(e.Id))
|
||||
.ToListAsync();
|
||||
|
||||
// Try to grab a worker user to assign as performed-by
|
||||
var worker = await _userManager.Users
|
||||
.Where(u => u.CompanyId == company.Id && u.IsActive)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var records = new List<MaintenanceRecord>();
|
||||
|
||||
// Per-equipment maintenance spec: (type, daysAgoFirst, intervalDays, laborCost, partsCost, notes)
|
||||
// Each equipment gets 2 completed records + 1 scheduled upcoming.
|
||||
// The Pressure Pot also gets 1 overdue record.
|
||||
static (string type, decimal labor, decimal parts, string desc, string work)
|
||||
MaintSpec(int i) => (i % 5) switch
|
||||
{
|
||||
0 => ("Preventive", 120m, 45m, "Quarterly preventive maintenance", "Inspected elements, cleaned contacts, checked gaskets"),
|
||||
1 => ("Inspection", 80m, 0m, "Monthly operational inspection", "Checked all systems, calibrated temperature probes"),
|
||||
2 => ("Repair", 200m, 185m, "Filter and seal replacement", "Replaced intake filters and worn door seals"),
|
||||
3 => ("Preventive", 140m, 60m, "Semi-annual preventive maintenance", "Lubricated moving parts, replaced wear items"),
|
||||
_ => ("Inspection", 60m, 0m, "Pre-season inspection and cleaning", "Full operational test, cleaned all surfaces"),
|
||||
};
|
||||
|
||||
int idx = 0;
|
||||
foreach (var eq in equipment)
|
||||
{
|
||||
var isPressurePot = eq.SerialNumber == "CLM101223456"; // Media Blast Room / Pressure Pot
|
||||
|
||||
for (int r = 0; r < 2; r++)
|
||||
{
|
||||
var (mtype, labor, parts, desc, work) = MaintSpec(idx + r);
|
||||
var daysAgo = 180 - r * 60 - (idx % 4) * 15;
|
||||
var scheduled = now.AddDays(-daysAgo);
|
||||
var total = labor + parts;
|
||||
|
||||
records.Add(new MaintenanceRecord
|
||||
{
|
||||
EquipmentId = eq.Id,
|
||||
MaintenanceType = mtype,
|
||||
Status = MaintenanceStatus.Completed,
|
||||
Priority = MaintenancePriority.Normal,
|
||||
ScheduledDate = scheduled,
|
||||
CompletedDate = scheduled.AddDays(1),
|
||||
PerformedById = worker?.Id,
|
||||
AssignedUserId = worker?.Id,
|
||||
Description = desc,
|
||||
WorkPerformed = work,
|
||||
LaborCost = labor,
|
||||
PartsCost = parts,
|
||||
TotalCost = total,
|
||||
DowntimeHours = 2m + r,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = scheduled.AddDays(-7)
|
||||
});
|
||||
}
|
||||
|
||||
// One upcoming scheduled record
|
||||
var upcomingDays = 15 + (idx % 30);
|
||||
records.Add(new MaintenanceRecord
|
||||
{
|
||||
EquipmentId = eq.Id,
|
||||
MaintenanceType = "Preventive",
|
||||
Status = MaintenanceStatus.Scheduled,
|
||||
Priority = MaintenancePriority.Normal,
|
||||
ScheduledDate = now.AddDays(upcomingDays),
|
||||
AssignedUserId = worker?.Id,
|
||||
Description = "Scheduled preventive maintenance",
|
||||
LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now.AddDays(-7)
|
||||
});
|
||||
|
||||
// Overdue record for the pressure pot only
|
||||
if (isPressurePot)
|
||||
{
|
||||
records.Add(new MaintenanceRecord
|
||||
{
|
||||
EquipmentId = eq.Id,
|
||||
MaintenanceType = "Repair",
|
||||
Status = MaintenanceStatus.Scheduled,
|
||||
Priority = MaintenancePriority.High,
|
||||
ScheduledDate = now.AddDays(-20), // overdue
|
||||
AssignedUserId = worker?.Id,
|
||||
Description = "Filter replacement — OVERDUE",
|
||||
Notes = "Media filter became clogged ahead of schedule. Shop is running reduced blast capacity until repaired.",
|
||||
LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = now.AddDays(-30)
|
||||
});
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
|
||||
await _context.Set<MaintenanceRecord>().AddRangeAsync(records);
|
||||
await _context.SaveChangesAsync();
|
||||
|
||||
return records.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Infrastructure.Services;
|
||||
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 7 purchase orders across three vendors covering a 3-month window:
|
||||
/// 3 Received (historical), 2 Submitted (in-flight), and 2 Draft (pending approval).
|
||||
/// This gives every PO status a visible example for demo walkthroughs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Vendors are resolved by partial name match against the company's vendor list — the
|
||||
/// same approach used by <see cref="SeedBillsAsync"/>. PO numbers follow the convention
|
||||
/// <c>PO-YYMM-####</c> stamped at seed time.
|
||||
///
|
||||
/// Received POs link back to the <see cref="Bill"/> that was created after receipt when
|
||||
/// one with a matching vendor invoice number exists; otherwise <c>BillId</c> is left null
|
||||
/// so the PO still seeds cleanly even if bills were skipped.
|
||||
///
|
||||
/// Idempotency: returns 0 immediately if any purchase orders already exist for the company.
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed purchase orders for.</param>
|
||||
/// <returns>Number of PO records inserted, or 0 if already seeded.</returns>
|
||||
private async Task<int> SeedPurchaseOrdersAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<PurchaseOrder>()
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
|
||||
|
||||
if (existingCount > 0)
|
||||
return 0;
|
||||
|
||||
var vendors = await _context.Set<Vendor>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(v => v.CompanyId == company.Id && !v.IsDeleted)
|
||||
.ToListAsync();
|
||||
|
||||
if (vendors.Count == 0)
|
||||
return 0;
|
||||
|
||||
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.First();
|
||||
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.First();
|
||||
var harbor = vendors.FirstOrDefault(v => v.CompanyName.Contains("Harbor")) ?? vendors.First();
|
||||
var grainger = vendors.FirstOrDefault(v => v.CompanyName.Contains("Grainger")) ?? vendors.First();
|
||||
var localSupply = vendors.FirstOrDefault(v => v.CompanyName.Contains("Local")) ?? vendors.First();
|
||||
|
||||
// Resolve inventory item IDs for PO line items (optional — may be null if inventory
|
||||
// wasn't seeded yet; the PO seeds cleanly either way using the Description field).
|
||||
var glossBlack = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-GBK-001") && !i.IsDeleted);
|
||||
var matteBlack = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-MBK-001") && !i.IsDeleted);
|
||||
var superChrome = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-CHR-001") && !i.IsDeleted);
|
||||
var candyRed = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-CRD-001") && !i.IsDeleted);
|
||||
var blastMedia = await _context.Set<InventoryItem>().IgnoreQueryFilters()
|
||||
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-BLM-001") && !i.IsDeleted);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var pfx = $"PO-{now:yy}{now.Month:D2}-";
|
||||
var seq = 1;
|
||||
var seeded = 0;
|
||||
|
||||
async Task<PurchaseOrder> AddPO(PurchaseOrder po)
|
||||
{
|
||||
po.PoNumber = $"{pfx}{seq++:D4}";
|
||||
po.CompanyId = company.Id;
|
||||
foreach (var item in po.Items)
|
||||
{
|
||||
item.CompanyId = company.Id;
|
||||
item.CreatedAt = po.OrderDate;
|
||||
}
|
||||
await _context.Set<PurchaseOrder>().AddAsync(po);
|
||||
await _context.SaveChangesAsync();
|
||||
seeded++;
|
||||
return po;
|
||||
}
|
||||
|
||||
// ── RECEIVED (historical — tied to bills already in the system) ───────
|
||||
|
||||
// PO-1: Prismatic Powders — powder restock 3 months ago (Received, matches bill PP-77211)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = prismatic.Id,
|
||||
Status = PurchaseOrderStatus.Received,
|
||||
OrderDate = now.AddDays(-95),
|
||||
ExpectedDeliveryDate = now.AddDays(-80),
|
||||
ReceivedDate = now.AddDays(-82),
|
||||
SubTotal = 1_145.00m,
|
||||
TotalAmount = 1_145.00m,
|
||||
Notes = "Quarterly powder restock — Q1",
|
||||
CreatedAt = now.AddDays(-95),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = matteBlack?.Id, Description = "Matte Black Powder — 50 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 178.00m, LineTotal = 356.00m },
|
||||
new PurchaseOrderItem { InventoryItemId = glossBlack?.Id, Description = "Gloss Black Powder — 50 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 165.00m, LineTotal = 330.00m },
|
||||
new PurchaseOrderItem { Description = "Satin Silver Powder — 25 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 144.50m, LineTotal = 289.00m },
|
||||
new PurchaseOrderItem { Description = "Masking Tape & Plugs Kit", UnitOfMeasure = "kit", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 170.00m, LineTotal = 170.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-2: Columbia Coatings — specialty colors 2 months ago (Received, matches bill CC-4401)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = columbia.Id,
|
||||
Status = PurchaseOrderStatus.Received,
|
||||
OrderDate = now.AddDays(-70),
|
||||
ExpectedDeliveryDate = now.AddDays(-58),
|
||||
ReceivedDate = now.AddDays(-60),
|
||||
SubTotal = 986.00m,
|
||||
TotalAmount = 986.00m,
|
||||
Notes = "Specialty metallic & candy colors order",
|
||||
CreatedAt = now.AddDays(-70),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = candyRed?.Id, Description = "Candy Red Metallic — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 3, QuantityReceived = 3, UnitCost = 145.00m, LineTotal = 435.00m },
|
||||
new PurchaseOrderItem { InventoryItemId = superChrome?.Id, Description = "Chrome Effect Powder — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 168.00m, LineTotal = 336.00m },
|
||||
new PurchaseOrderItem { Description = "Hammertone Bronze — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 150.50m, LineTotal = 150.50m },
|
||||
new PurchaseOrderItem { Description = "Ground Straps & Hooks", UnitOfMeasure = "lot", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 64.50m, LineTotal = 64.50m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-3: Harbor Freight Tools — consumables 6 weeks ago (Received, matches bill HBF-18822 timing)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = harbor.Id,
|
||||
Status = PurchaseOrderStatus.Received,
|
||||
OrderDate = now.AddDays(-48),
|
||||
ExpectedDeliveryDate = now.AddDays(-40),
|
||||
ReceivedDate = now.AddDays(-42),
|
||||
SubTotal = 412.50m,
|
||||
TotalAmount = 412.50m,
|
||||
Notes = "Shop consumables & hardware restock",
|
||||
CreatedAt = now.AddDays(-48),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { Description = "J-Hook Hangers Assortment", UnitOfMeasure = "pkg", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 89.75m, LineTotal = 179.50m },
|
||||
new PurchaseOrderItem { Description = "Masking Caps — Mixed (100-pack)", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 60.00m, LineTotal = 120.00m },
|
||||
new PurchaseOrderItem { Description = "Wire Brushes & Abrasives", UnitOfMeasure = "lot", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 113.00m, LineTotal = 113.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// ── SUBMITTED (in-flight — awaiting delivery) ─────────────────────────
|
||||
|
||||
// PO-4: Grainger Industrial Supply — safety equipment & filter replacement (matches open GRG-7714 partial bill)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = grainger.Id,
|
||||
Status = PurchaseOrderStatus.Submitted,
|
||||
OrderDate = now.AddDays(-14),
|
||||
ExpectedDeliveryDate = now.AddDays(3),
|
||||
SubTotal = 648.00m,
|
||||
TotalAmount = 648.00m,
|
||||
Notes = "Blast room filter replacement + safety restocking",
|
||||
CreatedAt = now.AddDays(-14),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { Description = "HEPA Filter Cartridges — 12-pack", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 189.00m, LineTotal = 378.00m },
|
||||
new PurchaseOrderItem { Description = "Blast Nozzle Tungsten — 3/8\"", UnitOfMeasure = "ea", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 85.00m, LineTotal = 170.00m },
|
||||
new PurchaseOrderItem { Description = "Safety Respirators (10-pack)", UnitOfMeasure = "box", QuantityOrdered = 1, QuantityReceived = 0, UnitCost = 100.00m, LineTotal = 100.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-5: Prismatic Powders — current month powder order (matches open bill PP-88530)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = prismatic.Id,
|
||||
Status = PurchaseOrderStatus.Submitted,
|
||||
OrderDate = now.AddDays(-8),
|
||||
ExpectedDeliveryDate = now.AddDays(7),
|
||||
SubTotal = 1_050.00m,
|
||||
TotalAmount = 1_050.00m,
|
||||
Notes = "June powder restock — Matte Black + seasonal Gloss Red",
|
||||
CreatedAt = now.AddDays(-8),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = matteBlack?.Id, Description = "Matte Black Powder — 25 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 6, QuantityReceived = 0, UnitCost = 89.00m, LineTotal = 534.00m },
|
||||
new PurchaseOrderItem { Description = "Gloss Red Powder — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 132.00m, LineTotal = 264.00m },
|
||||
new PurchaseOrderItem { Description = "Hanging Racks (10-pack)", UnitOfMeasure = "pkg", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 126.00m, LineTotal = 252.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// ── DRAFT (pending review / approval) ─────────────────────────────────
|
||||
|
||||
// PO-6: Harbor Freight Tools — shop tools pending manager approval
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = harbor.Id,
|
||||
Status = PurchaseOrderStatus.Draft,
|
||||
OrderDate = now.AddDays(-3),
|
||||
ExpectedDeliveryDate = now.AddDays(10),
|
||||
SubTotal = 318.75m,
|
||||
TotalAmount = 318.75m,
|
||||
Notes = "Monthly hardware restock — needs approval before submit",
|
||||
InternalNotes = "Manager review requested: higher than normal month due to extra hooks for upcoming Apex Motorsports batch.",
|
||||
CreatedAt = now.AddDays(-3),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { Description = "Hanging Racks & J-Hooks", UnitOfMeasure = "pkg", QuantityOrdered = 1, QuantityReceived = 0, UnitCost = 198.75m, LineTotal = 198.75m },
|
||||
new PurchaseOrderItem { Description = "Masking Caps — Mixed (100-pack)", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 60.00m, LineTotal = 120.00m }
|
||||
}
|
||||
});
|
||||
|
||||
// PO-7: Local Industrial Supply — blast media restock (inventory currently at zero)
|
||||
await AddPO(new PurchaseOrder
|
||||
{
|
||||
VendorId = localSupply.Id,
|
||||
Status = PurchaseOrderStatus.Draft,
|
||||
OrderDate = now.AddDays(-1),
|
||||
ExpectedDeliveryDate = now.AddDays(5),
|
||||
SubTotal = 385.00m,
|
||||
TotalAmount = 385.00m,
|
||||
Notes = "URGENT — blast media out of stock, production blocked on Pressure Pot Blaster",
|
||||
InternalNotes = "Rush order requested; call LIS rep for same-week delivery.",
|
||||
CreatedAt = now.AddDays(-1),
|
||||
Items =
|
||||
{
|
||||
new PurchaseOrderItem { InventoryItemId = blastMedia?.Id, Description = "Aluminum Oxide #80 Grit — 100 lb bag", UnitOfMeasure = "bag", QuantityOrdered = 5, QuantityReceived = 0, UnitCost = 77.00m, LineTotal = 385.00m }
|
||||
}
|
||||
});
|
||||
|
||||
return seeded;
|
||||
}
|
||||
}
|
||||
@@ -6,39 +6,31 @@ namespace PowderCoating.Infrastructure.Services;
|
||||
public partial class SeedDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Seeds 75 realistic powder coating quotes spread across seven item categories
|
||||
/// (automotive wheels, industrial, architectural, fitness, marine, furniture, misc)
|
||||
/// with a realistic status distribution: Draft (8), Sent (12), Approved (35),
|
||||
/// Rejected (10), and Expired (10).
|
||||
/// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved,
|
||||
/// Rejected, and Expired — with Approved as the clear majority so that SeedJobsAsync
|
||||
/// has enough linked quotes to demonstrate the quote-to-job conversion workflow.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Idempotency: returns 0 immediately if any non-deleted quotes already exist for
|
||||
/// this company, preventing duplicate quote sets on repeated seed runs.
|
||||
/// Customers are loaded in Id-ascending order (matching the job seeder) and a
|
||||
/// shuffled visit schedule interleaves commercial and individual customers so that
|
||||
/// statuses and customer types are naturally mixed rather than appearing in blocks.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Quote numbers follow the production format <c>QT-YYMM-####</c>. The seeder scans
|
||||
/// existing numbers with the current month prefix and starts its sequence above the
|
||||
/// current maximum so seeded quotes never collide with real quotes created in the
|
||||
/// same month.
|
||||
/// Status pool (fixed seed 55): Approved ×18, Sent ×8, Draft ×4,
|
||||
/// Rejected ×3, Expired ×2. Shuffled with Fisher-Yates (fixed seed 55)
|
||||
/// so every reset produces the same interleaved sequence.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Pricing is deliberately simple (sqft × $8.50 + variance) rather than running through
|
||||
/// <c>IPricingCalculationService</c> — this avoids a dependency on company operating cost
|
||||
/// config that may not yet be populated when seed runs.
|
||||
/// Per-customer quote counts mirror real business activity: top commercial accounts
|
||||
/// (Carolina Fabrication, Apex, Triangle) generate the most quotes; prospect customers
|
||||
/// (Jennifer Clark, Lisa Anderson, Michelle Brown) have quotes but no jobs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tax-exempt customers automatically receive a 0 % tax rate (matching the production
|
||||
/// behaviour in <c>QuotesController</c>). Rush fees (15 %) are added every 12th quote.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The method requires that customers and quote-status lookup rows already exist for the
|
||||
/// company; it returns 0 if either dependency is missing so that the overall seed
|
||||
/// operation degrades gracefully rather than throwing.
|
||||
/// Dates spread over 7–185 days back (roughly 6 months) with a small jitter term so
|
||||
/// historical charts show a natural activity curve without obviously linear spacing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="company">The tenant company to seed quotes for.</param>
|
||||
/// <returns>Number of quotes inserted, or 0 if already seeded or dependencies are missing.</returns>
|
||||
private async Task<int> SeedQuotesAsync(Company company)
|
||||
{
|
||||
var existingCount = await _context.Set<Quote>()
|
||||
@@ -56,14 +48,14 @@ public partial class SeedDataService
|
||||
if (quoteStatuses.Count == 0)
|
||||
return 0;
|
||||
|
||||
// Load all commercial customers
|
||||
var customers = await _context.Set<Customer>()
|
||||
// Load in Id order — same ordering as the job seeder so CustomerProfile(ci) indices align
|
||||
var allCustomers = await _context.Set<Customer>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => c.CompanyId == company.Id && c.IsCommercial && !c.IsDeleted)
|
||||
.Where(c => c.CompanyId == company.Id && !c.IsDeleted)
|
||||
.OrderBy(c => c.Id)
|
||||
.ToListAsync();
|
||||
|
||||
if (customers.Count == 0)
|
||||
if (allCustomers.Count == 0)
|
||||
return 0;
|
||||
|
||||
var preparedByUser = await _userManager.Users
|
||||
@@ -71,8 +63,6 @@ public partial class SeedDataService
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Avoid duplicate quote numbers
|
||||
var prefix = $"QT-{now:yy}{now.Month:D2}-";
|
||||
var existing = await _context.Set<Quote>()
|
||||
.IgnoreQueryFilters()
|
||||
@@ -84,183 +74,203 @@ public partial class SeedDataService
|
||||
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
|
||||
var seq = maxNum + 1;
|
||||
|
||||
// ── Data arrays for varied, realistic content ─────────────────────────
|
||||
|
||||
// Returns an array of realistic item descriptions for a given category bucket (0–6).
|
||||
// Using a local static function keeps the description data close to where it is
|
||||
// consumed and avoids polluting the partial class with per-seeder detail arrays.
|
||||
static string[] ItemDescs(int category) => category switch
|
||||
// ── Per-customer quote counts (must sum to 35) ─────────────────────────
|
||||
// Indices match the Id-ascending customer list from SeedCustomersAsync:
|
||||
// 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
|
||||
// 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
|
||||
// 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
|
||||
// 10–26 = individual residential customers
|
||||
static int QuotesFor(int ci) => ci switch
|
||||
{
|
||||
0 => new[] {
|
||||
"18\" Aluminum Wheels — Matte Black",
|
||||
"17\" Steel Wheels — Gloss White",
|
||||
"20\" Alloy Wheels — Satin Silver",
|
||||
"16\" Chrome Replica Wheels — Gloss Black",
|
||||
"Motorcycle Frame — Flat Black",
|
||||
"Motorcycle Swingarm & Forks — Gloss Black",
|
||||
"Exhaust Headers — High-Temp Flat Black",
|
||||
"Intake Manifold — Wrinkle Red",
|
||||
"Valve Covers — Gloss Red",
|
||||
"Brake Calipers — Gloss Yellow" },
|
||||
1 => new[] {
|
||||
"Steel Shelving Units (10-shelf set)",
|
||||
"Industrial Equipment Frame",
|
||||
"Machine Guard Panels",
|
||||
"Conveyor Frame Sections",
|
||||
"Heavy Equipment Brackets",
|
||||
"Pump Housing Assembly",
|
||||
"Control Panel Enclosure",
|
||||
"Storage Rack System",
|
||||
"Scissor Lift Platform",
|
||||
"Compressor Tank" },
|
||||
2 => new[] {
|
||||
"Aluminum Window Frames (set of 8)",
|
||||
"Steel Handrail System — 40 ft",
|
||||
"Wrought Iron Fence Panels (6-panel set)",
|
||||
"Entry Gate — Custom Design",
|
||||
"Structural Steel Columns (set of 4)",
|
||||
"Balcony Railing — Satin Black",
|
||||
"Steel Door Frames (3 units)",
|
||||
"Architectural Steel Beams",
|
||||
"Decorative Ironwork — Stair Baluster",
|
||||
"Aluminum Storefront Frame" },
|
||||
3 => new[] {
|
||||
"Commercial Gym Equipment Frame",
|
||||
"Weight Rack & Benches",
|
||||
"Outdoor Playground Equipment Parts",
|
||||
"Bicycle Frame — Gloss Blue",
|
||||
"BMX Frame Set — Candy Red" },
|
||||
4 => new[] {
|
||||
"Boat Trailer Frame — Marine Grade",
|
||||
"Aluminum Dock Cleats & Hardware",
|
||||
"Outboard Motor Bracket",
|
||||
"Marine Fuel Tank Brackets" },
|
||||
5 => new[] {
|
||||
"Restaurant Chair Frames (set of 20)",
|
||||
"Steel Dining Table Bases (set of 8)",
|
||||
"Patio Furniture Set — 6 Pieces",
|
||||
"Café Chairs — Hammered Bronze (12-pc)",
|
||||
"Commercial Bar Stools (set of 10)" },
|
||||
_ => new[] {
|
||||
"Custom Steel Parts — Batch Order",
|
||||
"Agricultural Equipment Panels",
|
||||
"Traffic Sign Frames (set of 15)",
|
||||
"Utility Trailer Hitch Assembly",
|
||||
"Solar Panel Mounting Brackets" }
|
||||
0 => 4, // Carolina Fabrication — most quotes
|
||||
1 => 3, // Apex Motorsports
|
||||
2 => 3, // Triangle Offroad
|
||||
3 => 3, // Smith Welding
|
||||
4 => 2, // Raleigh Architectural Metals
|
||||
5 => 2, // East Coast Powderworks
|
||||
6 => 1, // Piedmont Metal Works
|
||||
7 => 1, // Cary Industrial Solutions
|
||||
8 => 2, // Durham Tech Equipment
|
||||
9 => 3, // Wake County Fleet Services
|
||||
10 => 1, // John Davis
|
||||
11 => 1, // Sarah Jenkins
|
||||
12 => 1, // Mike Thompson
|
||||
13 => 1, // Robert Miller
|
||||
14 => 1, // Jennifer Clark — prospect (quote only, no job)
|
||||
15 => 1, // David Wilson
|
||||
16 => 1, // Lisa Anderson — prospect
|
||||
17 => 1, // Thomas Harris
|
||||
19 => 1, // James Taylor
|
||||
20 => 1, // Michelle Brown — prospect
|
||||
21 => 1, // Chris Lee
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
// Returns finish color, prep flags, estimated minutes, and surface area for item index i.
|
||||
// Cycling modulo 9 ensures variety across all 75 quotes without requiring a large lookup table.
|
||||
static (string color, bool sandblast, bool mask, int minutes, decimal sqft) ItemSpec(int i) => (i % 9) switch
|
||||
// ── Status pool: realistic distribution (total = 35) ───────────────────
|
||||
// Approved is the majority; Rejected and Expired are rare.
|
||||
var statusPool = new List<string>();
|
||||
foreach (var (code, count) in new (string Code, int Count)[]
|
||||
{
|
||||
0 => ("Matte Black", true, false, 45, 12.0m),
|
||||
1 => ("Gloss White", false, false, 30, 8.5m),
|
||||
2 => ("Satin Silver", true, true, 60, 15.0m),
|
||||
3 => ("Candy Red", false, true, 35, 9.0m),
|
||||
4 => ("Textured Gray", true, false, 50, 18.0m),
|
||||
5 => ("Gloss Black", true, false, 40, 11.0m),
|
||||
6 => ("Hammered Bronze", false, false, 55, 20.0m),
|
||||
7 => ("Satin Graphite", true, true, 65, 25.0m),
|
||||
_ => ("Flat Black", true, false, 35, 10.0m)
|
||||
};
|
||||
|
||||
// Maps quote index to a status code following the distribution plan above.
|
||||
// APPROVED is the majority (35/75) to give SeedJobsAsync enough approved quotes to link jobs to.
|
||||
static string StatusFor(int i) => i switch
|
||||
("APPROVED", 18),
|
||||
("SENT", 8),
|
||||
("DRAFT", 4),
|
||||
("REJECTED", 3),
|
||||
("EXPIRED", 2),
|
||||
})
|
||||
{
|
||||
< 8 => "DRAFT",
|
||||
< 20 => "SENT",
|
||||
< 55 => "APPROVED",
|
||||
< 65 => "REJECTED",
|
||||
_ => "EXPIRED"
|
||||
};
|
||||
|
||||
var quotes = new List<Quote>();
|
||||
|
||||
for (int i = 0; i < 75; i++)
|
||||
for (int k = 0; k < count; k++) statusPool.Add(code);
|
||||
}
|
||||
// Fisher-Yates shuffle — fixed seed for deterministic resets
|
||||
var statusRng = new Random(55);
|
||||
for (int k = statusPool.Count - 1; k > 0; k--)
|
||||
{
|
||||
var customer = customers[i % customers.Count];
|
||||
var statusCode = StatusFor(i);
|
||||
var swap = statusRng.Next(k + 1);
|
||||
(statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]);
|
||||
}
|
||||
|
||||
// Spread creation dates over the past 90 days; older first
|
||||
var daysAgo = 90 - (int)(i * 1.2);
|
||||
// ── Customer visit schedule: interleave commercial (ci 0–9) and individual (ci 10+) ──
|
||||
// Same two-pool approach as the job seeder — prevents commercial customers from
|
||||
// clustering at the top of any list sorted by quote number or date.
|
||||
var commercialVisits = new List<int>();
|
||||
var individualVisits = new List<int>();
|
||||
for (int ci = 0; ci < allCustomers.Count; ci++)
|
||||
{
|
||||
var count = QuotesFor(ci);
|
||||
for (int j = 0; j < count; j++)
|
||||
(ci < 10 ? commercialVisits : individualVisits).Add(ci);
|
||||
}
|
||||
var rngC = new Random(42);
|
||||
for (int k = commercialVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngC.Next(k + 1);
|
||||
(commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]);
|
||||
}
|
||||
var rngI = new Random(17);
|
||||
for (int k = individualVisits.Count - 1; k > 0; k--)
|
||||
{
|
||||
var swap = rngI.Next(k + 1);
|
||||
(individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]);
|
||||
}
|
||||
var visitSchedule = new List<int>(commercialVisits.Count + individualVisits.Count);
|
||||
double indStride = individualVisits.Count > 0
|
||||
? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0)
|
||||
: double.MaxValue;
|
||||
int indInsertIdx = 0;
|
||||
for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++)
|
||||
{
|
||||
while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
visitSchedule.Add(commercialVisits[comIdx]);
|
||||
}
|
||||
while (indInsertIdx < individualVisits.Count)
|
||||
visitSchedule.Add(individualVisits[indInsertIdx++]);
|
||||
|
||||
// ── Item catalogue — 15 common powder coating jobs ──────────────────────
|
||||
static (string desc, string color, bool sand, bool mask, int mins, decimal sqft) ItemSpec(int i) =>
|
||||
(i % 15) switch
|
||||
{
|
||||
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45, 12.0m),
|
||||
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30, 8.5m),
|
||||
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60, 15.0m),
|
||||
3 => ("Motorcycle Frame", "Matte Black", true, false, 90, 14.0m),
|
||||
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55, 18.0m),
|
||||
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35, 20.0m),
|
||||
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50, 22.0m),
|
||||
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120, 40.0m),
|
||||
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180, 35.0m),
|
||||
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35, 4.0m),
|
||||
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60, 30.0m),
|
||||
11 => ("Bicycle Frame", "Candy Red", true, true, 60, 6.5m),
|
||||
12 => ("Compressor Tank", "Safety Orange", true, false, 45, 10.0m),
|
||||
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50, 24.0m),
|
||||
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40, 15.0m)
|
||||
};
|
||||
|
||||
var quotes = new List<Quote>();
|
||||
var quotesByCustomer = new int[allCustomers.Count]; // within-customer quote counter
|
||||
|
||||
for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++)
|
||||
{
|
||||
var ci = visitSchedule[visitIdx];
|
||||
var customer = allCustomers[ci];
|
||||
var j = quotesByCustomer[ci]++; // within-customer quote index (unused except for date jitter)
|
||||
var statusCode = statusPool[visitIdx];
|
||||
|
||||
// Dates spread 7–185 days back; small jitter via ci so identical-status quotes
|
||||
// for the same customer don't land on exactly the same day.
|
||||
var daysAgo = Math.Max(7, 185 - visitIdx * 5 + (ci % 5));
|
||||
var quoteDate = now.AddDays(-daysAgo);
|
||||
var expireDate = quoteDate.AddDays(30);
|
||||
|
||||
var category = i % 7;
|
||||
var descs = ItemDescs(category);
|
||||
var itemCount = 1 + (i % 3);
|
||||
|
||||
var itemCount = 1 + (visitIdx % 3);
|
||||
var items = new List<QuoteItem>();
|
||||
for (int j = 0; j < itemCount; j++)
|
||||
|
||||
for (int k = 0; k < itemCount; k++)
|
||||
{
|
||||
var desc = descs[(i + j) % descs.Length];
|
||||
var (color, sand, mask, mins, sqft) = ItemSpec(i + j);
|
||||
var qty = 1 + (j % 4);
|
||||
// Unit price scales with surface area and adds a modest multiplier per customer tier
|
||||
var tierMult = 1m + ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m * -1m);
|
||||
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 5) * 4.5m, 2);
|
||||
var (desc, color, sand, mask, mins, sqft) = ItemSpec(visitIdx * 3 + k);
|
||||
var qty = 1 + (k % 4);
|
||||
var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m);
|
||||
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (visitIdx % 6) * 5.0m, 2);
|
||||
|
||||
items.Add(new QuoteItem
|
||||
{
|
||||
Description = desc,
|
||||
Quantity = qty,
|
||||
SurfaceAreaSqFt = sqft * qty,
|
||||
UnitPrice = unitPrice,
|
||||
TotalPrice = unitPrice * qty,
|
||||
Description = desc,
|
||||
Quantity = qty,
|
||||
SurfaceAreaSqFt = sqft * qty,
|
||||
UnitPrice = unitPrice,
|
||||
TotalPrice = unitPrice * qty,
|
||||
RequiresSandblasting = sand,
|
||||
RequiresMasking = mask,
|
||||
EstimatedMinutes = mins,
|
||||
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
|
||||
Notes = j == 0 && i % 5 == 0 ? $"{color} finish requested" : null,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = quoteDate
|
||||
RequiresMasking = mask,
|
||||
EstimatedMinutes = mins,
|
||||
Complexity = (visitIdx % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
|
||||
Notes = k == 0 && visitIdx % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = quoteDate
|
||||
});
|
||||
}
|
||||
|
||||
var subtotal = items.Sum(it => it.TotalPrice);
|
||||
var discountPct = customer.PricingTier?.DiscountPercent ?? 0m;
|
||||
var discountAmt = Math.Round(subtotal * discountPct / 100m, 2);
|
||||
var afterDiscount = subtotal - discountAmt;
|
||||
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
|
||||
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
|
||||
var rushFee = i % 12 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
|
||||
var total = afterDiscount + taxAmt + rushFee;
|
||||
var subtotal = items.Sum(it => it.TotalPrice);
|
||||
var discountPct = customer.PricingTier?.DiscountPercent ?? 0m;
|
||||
var discountAmt = Math.Round(subtotal * discountPct / 100m, 2);
|
||||
var afterDiscount = subtotal - discountAmt;
|
||||
var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
|
||||
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
|
||||
var rushFee = visitIdx % 10 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
|
||||
var total = afterDiscount + taxAmt + rushFee;
|
||||
|
||||
var quote = new Quote
|
||||
quotes.Add(new Quote
|
||||
{
|
||||
QuoteNumber = $"{prefix}{seq:D4}",
|
||||
CustomerId = customer.Id,
|
||||
PreparedById = preparedByUser?.Id,
|
||||
QuoteStatusId = quoteStatuses[statusCode],
|
||||
IsCommercial = customer.IsCommercial,
|
||||
IsRushJob = i % 12 == 0,
|
||||
QuoteDate = quoteDate,
|
||||
ExpirationDate = expireDate,
|
||||
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
|
||||
ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null,
|
||||
ItemsSubtotal = subtotal,
|
||||
SubTotal = subtotal,
|
||||
DiscountPercent = discountPct,
|
||||
DiscountAmount = discountAmt,
|
||||
TaxPercent = taxPct,
|
||||
TaxAmount = taxAmt,
|
||||
RushFee = rushFee,
|
||||
Total = total,
|
||||
Description = $"Powder coating services — {descs[i % descs.Length].Split('—')[0].Trim()}",
|
||||
Terms = customer.PaymentTerms ?? "Net 30",
|
||||
Notes = i % 7 == 0 ? "Customer requested color sample before full run." :
|
||||
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
|
||||
CustomerPO = i % 2 == 0 ? $"PO-{30000 + i}" : null,
|
||||
RequiresDeposit = i % 4 == 0,
|
||||
DepositPercent = i % 4 == 0 ? 50m : 0m,
|
||||
QuoteItems = items,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = quoteDate,
|
||||
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
|
||||
};
|
||||
QuoteNumber = $"{prefix}{seq:D4}",
|
||||
CustomerId = customer.Id,
|
||||
PreparedById = preparedByUser?.Id,
|
||||
QuoteStatusId = quoteStatuses[statusCode],
|
||||
IsCommercial = customer.IsCommercial,
|
||||
IsRushJob = visitIdx % 10 == 0,
|
||||
QuoteDate = quoteDate,
|
||||
ExpirationDate = expireDate,
|
||||
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
|
||||
ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null,
|
||||
ItemsSubtotal = subtotal,
|
||||
SubTotal = subtotal,
|
||||
DiscountPercent = discountPct,
|
||||
DiscountAmount = discountAmt,
|
||||
TaxPercent = taxPct,
|
||||
TaxAmount = taxAmt,
|
||||
RushFee = rushFee,
|
||||
Total = total,
|
||||
Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}",
|
||||
Terms = customer.PaymentTerms ?? "Net 30",
|
||||
Notes = visitIdx % 8 == 0 ? "Customer requested color sample before full production run." :
|
||||
visitIdx % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
|
||||
CustomerPO = customer.IsCommercial && visitIdx % 2 == 0 ? $"PO-{30000 + visitIdx}" : null,
|
||||
RequiresDeposit = visitIdx % 4 == 0,
|
||||
DepositPercent = visitIdx % 4 == 0 ? 50m : 0m,
|
||||
QuoteItems = items,
|
||||
CompanyId = company.Id,
|
||||
CreatedAt = quoteDate,
|
||||
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
|
||||
});
|
||||
|
||||
quotes.Add(quote);
|
||||
seq++;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user