Compare commits
88 Commits
v2026.05.22b
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c0e4a66126 | |||
| dbd39a9fe5 | |||
| 584664e7c8 | |||
| 1255bc0670 | |||
| 01f6897d08 | |||
| 72382a5dd5 | |||
| 86a293a927 | |||
| 35264e6b2a | |||
| 0b839d0746 | |||
| 66c3febd7a | |||
| b8057295ec | |||
| 14d6c82839 | |||
| db4b73013a | |||
| e313149f08 | |||
| 82fb48f7a5 | |||
| 427c52a499 | |||
| d92266b027 | |||
| 750e1b1c5b | |||
| 94a89ee175 | |||
| 711cd01cd3 | |||
| 7cbae31916 | |||
| 9367e358d9 | |||
| 9f1460c9c0 | |||
| 94e536178c | |||
| 456d054229 | |||
| f38a1e3273 | |||
| 03b425a12f | |||
| 8453449833 | |||
| ad986561c9 | |||
| 0d5553f3b2 | |||
| 87bbf158a4 | |||
| f453a95f28 | |||
| d9e98a55d2 | |||
| 99deca3b62 | |||
| 23e64829bb | |||
| cd4c233b60 | |||
| 6c07216c64 | |||
| b23bea6db0 | |||
| cf07356147 | |||
| 39b103a482 | |||
| 4aae2df5b5 | |||
| 3416c242f1 | |||
| 7e31846777 | |||
| ed35362c7a | |||
| 81119035c7 | |||
| 0deef574c3 | |||
| efc4e9dadf | |||
| ca7e905832 | |||
| 32d09b38f1 | |||
| 3cee1307fc | |||
| be89327c01 | |||
| 8f955851e5 | |||
| 972123c7a2 | |||
| 9dd36238bb | |||
| 8ae61b6c78 | |||
| 97745f9a65 | |||
| e124fd5c8b | |||
| 6c2fe6e1c4 | |||
| f625be01a3 | |||
| e6c4cfb38b | |||
| 5b5247624c | |||
| 91a176ce5c | |||
| a7ad0e1de8 | |||
| e4a256a6c4 | |||
| e476b4744d | |||
| 04d16109ae | |||
| f0f3717681 | |||
| e23b006139 | |||
| 0f35946973 | |||
| 19e1ce858f | |||
| 026e646295 | |||
| b7fcefa765 | |||
| 1722cd4124 | |||
| c3742e1585 | |||
| 1a6f855c05 | |||
| d28e639d1b | |||
| 10f668fd73 | |||
| 19b7a9a473 | |||
| 4650ba3d4d | |||
| 1eba50cf0f | |||
| e443457139 | |||
| edf56c1164 | |||
| b9cd693421 | |||
| d77b3778ac | |||
| a7bf97a2df | |||
| 05935b110a | |||
| 64a9c1531b | |||
| f018653c18 |
@@ -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>
|
/// <summary>True when the company has an accepted agreement for the current SmsTermsVersion.</summary>
|
||||||
public bool HasCurrentSmsAgreement { get; set; }
|
public bool HasCurrentSmsAgreement { get; set; }
|
||||||
public string SmsTermsVersion { get; set; } = string.Empty;
|
public string SmsTermsVersion { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// Timeclock settings
|
||||||
|
public bool TimeclockEnabled { get; set; }
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>DTO for updating company-level timeclock settings from the Settings tab.</summary>
|
||||||
|
public class UpdateTimeclockSettingsDto
|
||||||
|
{
|
||||||
|
public bool TimeclockEnabled { get; set; }
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; }
|
||||||
|
[Range(1, 24, ErrorMessage = "Auto clock-out must be between 1 and 24 hours.")]
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,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 bool NotifyBySms { get; set; }
|
||||||
public DateTime? SmsConsentedAt { get; set; }
|
public DateTime? SmsConsentedAt { get; set; }
|
||||||
public string? SmsConsentMethod { get; set; }
|
public string? SmsConsentMethod { get; set; }
|
||||||
|
|
||||||
|
// CRM
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
// Ship-to address
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class CreateCustomerDto : IValidatableObject
|
public class CreateCustomerDto : IValidatableObject
|
||||||
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
|
|||||||
[StringLength(2000)]
|
[StringLength(2000)]
|
||||||
public string? GeneralNotes { get; set; }
|
public string? GeneralNotes { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "How did you find us?")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
// Ship-to / alternate address
|
||||||
|
[Display(Name = "Ship-To Street Address")]
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "City")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "State")]
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Zip Code")]
|
||||||
|
[StringLength(20)]
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Country")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Notify by Email")]
|
[Display(Name = "Notify by Email")]
|
||||||
public bool NotifyByEmail { get; set; } = true;
|
public bool NotifyByEmail { get; set; } = true;
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class EquipmentDto
|
|||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
public int? DaysUntilMaintenance { get; set; }
|
public int? DaysUntilMaintenance { get; set; }
|
||||||
@@ -101,7 +101,7 @@ public class CreateEquipmentDto
|
|||||||
|
|
||||||
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
[Range(1, 3650, ErrorMessage = "Maintenance interval must be between 1 and 3650 days")]
|
||||||
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
[Display(Name = "Recommended Maintenance Interval (Days)")]
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Last Maintenance Date")]
|
[Display(Name = "Last Maintenance Date")]
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
|
|||||||
@@ -63,4 +63,22 @@ public class CustomerImportDto
|
|||||||
|
|
||||||
[Name("Notes")]
|
[Name("Notes")]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
[Name("LeadSource")]
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToAddress")]
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToCity")]
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToState")]
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToZipCode")]
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
|
||||||
|
[Name("ShipToCountry")]
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,9 @@ public class InvoiceImportDto
|
|||||||
[Name("DueDate")]
|
[Name("DueDate")]
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
|
|
||||||
|
[Name("Project Name", "ProjectName")]
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Name("SubTotal")]
|
[Name("SubTotal")]
|
||||||
public decimal SubTotal { get; set; }
|
public decimal SubTotal { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ public class JobImportDto
|
|||||||
[Name("CustomerName")]
|
[Name("CustomerName")]
|
||||||
public string? CustomerName { get; set; }
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
// Optional short label for the job (maps directly to Job.Description).
|
||||||
|
// When blank, the system falls back to SpecialInstructions, then "Imported job".
|
||||||
|
[Name("Description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
[Name("Status")]
|
[Name("Status")]
|
||||||
public string Status { get; set; } = "Pending";
|
public string Status { get; set; } = "Pending";
|
||||||
|
|
||||||
@@ -44,6 +49,9 @@ public class JobImportDto
|
|||||||
[Name("SpecialInstructions")]
|
[Name("SpecialInstructions")]
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
|
|
||||||
|
[Name("ProjectName")]
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Name("Notes")]
|
[Name("Notes")]
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,9 @@ public class QuoteImportDto
|
|||||||
[Name("ExpirationDate")]
|
[Name("ExpirationDate")]
|
||||||
public DateTime? ExpirationDate { get; set; }
|
public DateTime? ExpirationDate { get; set; }
|
||||||
|
|
||||||
|
[Name("ProjectName")]
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Name("Subtotal")]
|
[Name("Subtotal")]
|
||||||
public decimal Subtotal { get; set; }
|
public decimal Subtotal { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ public class InvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? ExternalReference { get; set; }
|
public string? ExternalReference { get; set; }
|
||||||
public int? SalesTaxAccountId { get; set; }
|
public int? SalesTaxAccountId { get; set; }
|
||||||
public string? SalesTaxAccountName { get; set; }
|
public string? SalesTaxAccountName { get; set; }
|
||||||
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||||
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ public class JobDto
|
|||||||
public decimal DiscountValue { get; set; }
|
public decimal DiscountValue { get; set; }
|
||||||
public string? DiscountReason { get; set; }
|
public string? DiscountReason { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
@@ -113,6 +114,8 @@ public class JobListDto
|
|||||||
|
|
||||||
public string? CustomerEmail { get; set; }
|
public string? CustomerEmail { get; set; }
|
||||||
public bool CustomerNotifyByEmail { get; set; } = true;
|
public bool CustomerNotifyByEmail { get; set; } = true;
|
||||||
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public DateTime? ScheduledDate { get; set; }
|
public DateTime? ScheduledDate { get; set; }
|
||||||
public DateTime? DueDate { get; set; }
|
public DateTime? DueDate { get; set; }
|
||||||
public decimal FinalPrice { get; set; }
|
public decimal FinalPrice { get; set; }
|
||||||
@@ -166,6 +169,7 @@ public class CreateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
@@ -251,6 +255,7 @@ public class UpdateJobDto
|
|||||||
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
|
||||||
[Display(Name = "Customer PO")]
|
[Display(Name = "Customer PO")]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
|
||||||
[Display(Name = "Special Instructions")]
|
[Display(Name = "Special Instructions")]
|
||||||
@@ -325,7 +330,11 @@ public class JobItemDto
|
|||||||
public bool IsGenericItem { get; set; }
|
public bool IsGenericItem { get; set; }
|
||||||
public bool IsLaborItem { get; set; }
|
public bool IsLaborItem { get; set; }
|
||||||
public bool IsSalesItem { get; set; }
|
public bool IsSalesItem { get; set; }
|
||||||
|
public bool IsAiItem { get; set; }
|
||||||
public string? Sku { get; set; }
|
public string? Sku { get; set; }
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
public List<JobItemCoatDto> Coats { get; set; } = new();
|
public List<JobItemCoatDto> Coats { get; set; } = new();
|
||||||
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
public List<JobItemPrepServiceDto> PrepServices { get; set; } = new();
|
||||||
}
|
}
|
||||||
@@ -486,6 +495,7 @@ public class ReworkRecordDto
|
|||||||
public decimal ActualReworkCost { get; set; }
|
public decimal ActualReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
public PowderCoating.Core.Enums.ReworkStatus Status { get; set; }
|
||||||
public string StatusDisplay { get; set; } = string.Empty;
|
public string StatusDisplay { get; set; } = string.Empty;
|
||||||
@@ -511,6 +521,11 @@ public class CreateReworkRecordDto
|
|||||||
public decimal EstimatedReworkCost { get; set; }
|
public decimal EstimatedReworkCost { get; set; }
|
||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Rework job creation (opt-in)
|
||||||
|
public bool CreateReworkJob { get; set; }
|
||||||
|
public List<int>? ReworkJobItemIds { get; set; } // null = not creating a job
|
||||||
|
public PowderCoating.Core.Enums.ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateReworkRecordDto
|
public class UpdateReworkRecordDto
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ public class QuoteDto
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Items
|
// Items
|
||||||
@@ -234,6 +235,7 @@ public class CreateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
|
|||||||
[Display(Name = "Customer PO Number")]
|
[Display(Name = "Customer PO Number")]
|
||||||
[StringLength(50)]
|
[StringLength(50)]
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
[Display(Name = "Tags")]
|
[Display(Name = "Tags")]
|
||||||
[StringLength(500)]
|
[StringLength(500)]
|
||||||
@@ -475,6 +478,11 @@ public class QuoteItemDto
|
|||||||
|
|
||||||
public bool IsAiItem { get; set; }
|
public bool IsAiItem { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Cost breakdown snapshot
|
// Cost breakdown snapshot
|
||||||
public decimal ItemMaterialCost { get; set; }
|
public decimal ItemMaterialCost { get; set; }
|
||||||
public decimal ItemLaborCost { get; set; }
|
public decimal ItemLaborCost { get; set; }
|
||||||
@@ -559,6 +567,11 @@ public class CreateQuoteItemDto
|
|||||||
|
|
||||||
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
// ID of the AiItemPrediction record captured at analysis time (null for non-AI items)
|
||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item routing — see IsCustomFormulaItem in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -874,4 +887,9 @@ public class QuotePricingResult
|
|||||||
|
|
||||||
// Per-item results (same order as input items)
|
// Per-item results (same order as input items)
|
||||||
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
public List<QuoteItemPricingResult> ItemResults { get; set; } = new();
|
||||||
|
|
||||||
|
// Pending Custom Powder Order preview — populated only when no "Custom Powder Order" item
|
||||||
|
// exists yet (first save scenario). Amount and color list let the UI show a preview row.
|
||||||
|
public decimal CustomPowderOrderAmount { get; set; }
|
||||||
|
public List<string> CustomPowderOrderColors { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class SubscriptionPlanConfigDto
|
|||||||
public bool AllowAiInventoryAssist { get; set; }
|
public bool AllowAiInventoryAssist { get; set; }
|
||||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||||
public bool AllowSms { get; set; }
|
public bool AllowSms { get; set; }
|
||||||
|
public bool AllowCustomFormulas { get; set; }
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
@@ -74,6 +75,7 @@ public class UpdateSubscriptionPlanConfigDto
|
|||||||
public bool AllowAiInventoryAssist { get; set; }
|
public bool AllowAiInventoryAssist { get; set; }
|
||||||
public bool AllowAiCatalogPriceCheck { get; set; }
|
public bool AllowAiCatalogPriceCheck { get; set; }
|
||||||
public bool AllowSms { get; set; }
|
public bool AllowSms { get; set; }
|
||||||
|
public bool AllowCustomFormulas { get; set; }
|
||||||
|
|
||||||
public bool IsActive { get; set; }
|
public bool IsActive { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Timeclock;
|
||||||
|
|
||||||
|
public class EmployeeClockEntryDto
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string UserDisplayName { get; set; } = string.Empty;
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public decimal? HoursWorked { get; set; }
|
||||||
|
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
public bool IsOpen => ClockOutTime == null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClockInRequest
|
||||||
|
{
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ClockOutRequest
|
||||||
|
{
|
||||||
|
public int EntryId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Request sent from the kiosk tablet — employee taps their tile and enters a PIN.
|
||||||
|
/// The server determines whether to clock in or clock out based on the employee's open entry.
|
||||||
|
/// </summary>
|
||||||
|
public class KioskPunchRequest
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string Pin { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EditClockEntryRequest
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sent when an employee clicks Break or Lunch to pause their work segment.
|
||||||
|
/// The server closes the current Work entry and opens a Break/Lunch entry.
|
||||||
|
/// </summary>
|
||||||
|
public class GoOnBreakRequest
|
||||||
|
{
|
||||||
|
/// <summary>Must be <see cref="ClockEntryType.Break"/> or <see cref="ClockEntryType.Lunch"/>.</summary>
|
||||||
|
public ClockEntryType BreakType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Manager request to create a time entry on behalf of any company employee.</summary>
|
||||||
|
public class ManualEntryRequest
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Employee tile shown on the kiosk employee-selection grid.</summary>
|
||||||
|
public class KioskEmployeeDto
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
public string DisplayName { get; set; } = string.Empty;
|
||||||
|
public string Initials { get; set; } = string.Empty;
|
||||||
|
/// <summary>True when the employee has an open clock entry right now.</summary>
|
||||||
|
public bool IsClockedIn { get; set; }
|
||||||
|
}
|
||||||
@@ -125,6 +125,8 @@ public class CreateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -209,4 +211,6 @@ public class UpdateVendorDto
|
|||||||
|
|
||||||
[Display(Name = "Default Expense Account")]
|
[Display(Name = "Default Expense Account")]
|
||||||
public int? DefaultExpenseAccountId { get; set; }
|
public int? DefaultExpenseAccountId { get; set; }
|
||||||
|
|
||||||
|
public List<int> CategoryIds { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ public class WizardProgressDto
|
|||||||
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
public bool IsStepSkipped(int step) => SkippedSteps.Contains(step);
|
||||||
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
public bool IsStepTouched(int step) => IsStepDone(step) || IsStepSkipped(step);
|
||||||
|
|
||||||
public int CompletedCount => DoneSteps.Count + SkippedSteps.Count;
|
// Capped at TotalSteps so old step data from a larger wizard doesn't overflow the display.
|
||||||
|
public int CompletedCount => Math.Min(DoneSteps.Count + SkippedSteps.Count, TotalSteps);
|
||||||
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
public int ProgressPercent => TotalSteps == 0 ? 0 : (int)Math.Round((double)CompletedCount / TotalSteps * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
int companyId,
|
||||||
decimal? ovenRateOverride,
|
decimal? ovenRateOverride,
|
||||||
DateTime createdAtUtc);
|
DateTime createdAtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates one <see cref="InventoryItem"/> (IsIncoming=true) per unique powder catalog entry
|
||||||
|
/// referenced by coats on the given quote, then links those coats to the new inventory records.
|
||||||
|
/// Must be called after a quote transitions to Approved status.
|
||||||
|
/// Safe to call multiple times — coats that already have an InventoryItemId are skipped.
|
||||||
|
/// </summary>
|
||||||
|
Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ public class RemoveSeedDataOptions
|
|||||||
public bool Catalog { get; set; }
|
public bool Catalog { get; set; }
|
||||||
public bool PricingTiers { get; set; }
|
public bool PricingTiers { get; set; }
|
||||||
public bool OperatingCosts { get; set; }
|
public bool OperatingCosts { get; set; }
|
||||||
|
public bool Bills { get; set; }
|
||||||
|
public bool Expenses { get; set; }
|
||||||
|
public bool Workers { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class SeedDataResult
|
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)
|
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
|
||||||
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
|
||||||
: string.Empty));
|
: string.Empty));
|
||||||
|
|
||||||
|
// CustomerContact
|
||||||
|
CreateMap<CustomerContact, CustomerContactDto>();
|
||||||
|
CreateMap<CreateCustomerContactDto, CustomerContact>();
|
||||||
|
CreateMap<UpdateCustomerContactDto, CustomerContact>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
|
||||||
|
CreateMap<CustomerContact, UpdateCustomerContactDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Application.DTOs.Company;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Mappings;
|
||||||
|
|
||||||
|
public class FormulaLibraryProfile : Profile
|
||||||
|
{
|
||||||
|
public FormulaLibraryProfile()
|
||||||
|
{
|
||||||
|
CreateMap<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||||
|
.ForMember(dest => dest.InspiredByName,
|
||||||
|
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.Name : null))
|
||||||
|
.ForMember(dest => dest.InspiredByCompanyName,
|
||||||
|
opt => opt.MapFrom(src => src.InspiredBy != null ? src.InspiredBy.SourceCompanyName : null))
|
||||||
|
.ForMember(dest => dest.AlreadyImported, opt => opt.Ignore()); // set by service
|
||||||
|
|
||||||
|
CreateMap<FormulaLibraryItem, FormulaLibraryDetailDto>()
|
||||||
|
.IncludeBase<FormulaLibraryItem, FormulaLibraryCardDto>()
|
||||||
|
.ForMember(dest => dest.FieldCount,
|
||||||
|
opt => opt.MapFrom(src => CountFields(src.FieldsJson)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CountFields(string fieldsJson)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = System.Text.Json.JsonDocument.Parse(fieldsJson);
|
||||||
|
return doc.RootElement.ValueKind == System.Text.Json.JsonValueKind.Array
|
||||||
|
? doc.RootElement.GetArrayLength()
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
catch { return 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ public class InvoiceProfile : Profile
|
|||||||
|
|
||||||
CreateMap<Invoice, InvoiceDto>()
|
CreateMap<Invoice, InvoiceDto>()
|
||||||
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
.ForMember(d => d.JobNumber, o => o.MapFrom(s => s.Job != null ? s.Job.JobNumber : string.Empty))
|
||||||
|
.ForMember(d => d.ProjectName, o => o.MapFrom(s => s.ProjectName ?? (s.Job != null ? s.Job.ProjectName : null)))
|
||||||
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
.ForMember(d => d.CustomerName, o => o.MapFrom(s => s.Customer != null
|
||||||
? (s.Customer.IsCommercial
|
? (s.Customer.IsCommercial
|
||||||
? s.Customer.CompanyName
|
? s.Customer.CompanyName
|
||||||
|
|||||||
@@ -196,7 +196,9 @@ public class JobProfile : Profile
|
|||||||
.ForMember(dest => dest.JobItemDescription,
|
.ForMember(dest => dest.JobItemDescription,
|
||||||
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
opt => opt.MapFrom(src => src.JobItem != null ? src.JobItem.Description : null))
|
||||||
.ForMember(dest => dest.ReworkJobNumber,
|
.ForMember(dest => dest.ReworkJobNumber,
|
||||||
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null));
|
opt => opt.MapFrom(src => src.ReworkJob != null ? src.ReworkJob.JobNumber : null))
|
||||||
|
.ForMember(dest => dest.ReworkPricingType,
|
||||||
|
opt => opt.MapFrom(src => src.ReworkPricingType));
|
||||||
|
|
||||||
// Job → JobDto (rework fields)
|
// Job → JobDto (rework fields)
|
||||||
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
// (IsReworkJob and OriginalJobId map by convention; OriginalJobNumber needs explicit map — handled in controller)
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ public class QuoteProfile : Profile
|
|||||||
.ReverseMap()
|
.ReverseMap()
|
||||||
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
.ForMember(dest => dest.Quote, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
.ForMember(dest => dest.Coats, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
.ForMember(dest => dest.PrepServices, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||||
@@ -180,6 +181,7 @@ public class QuoteProfile : Profile
|
|||||||
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
.ForMember(dest => dest.Coats, opt => opt.Ignore()) // Mapped separately
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
.ForMember(dest => dest.PrepServices, opt => opt.Ignore()) // Mapped separately
|
||||||
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
.ForMember(dest => dest.CatalogItem, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.CustomItemTemplate, opt => opt.Ignore()) // FK only; nav set by EF
|
||||||
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
.ForMember(dest => dest.CompanyId, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
.ForMember(dest => dest.CreatedAt, opt => opt.Ignore())
|
||||||
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
.ForMember(dest => dest.UpdatedAt, opt => opt.Ignore())
|
||||||
@@ -190,7 +192,10 @@ public class QuoteProfile : Profile
|
|||||||
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
.ForMember(dest => dest.UpdatedBy, opt => opt.Ignore());
|
||||||
|
|
||||||
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
// QuoteItem -> CreateQuoteItemDto (for Edit view)
|
||||||
|
// Coats and PrepServices must be mapped explicitly; convention-based collection mapping
|
||||||
|
// is unreliable for ICollection<T> → List<T2> with different element types.
|
||||||
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
CreateMap<QuoteItem, CreateQuoteItemDto>()
|
||||||
|
.ForMember(dest => dest.Coats, opt => opt.MapFrom(src => src.Coats))
|
||||||
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
.ForMember(dest => dest.PrepServices, opt => opt.MapFrom(src => src.PrepServices));
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
|
||||||
|
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||||
|
|
||||||
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
<PackageReference Include="QuestPDF" Version="2024.12.3" />
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -157,7 +160,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -259,7 +265,10 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
IncludePrepCost = source.IncludePrepCost,
|
IncludePrepCost = source.IncludePrepCost,
|
||||||
Complexity = source.Complexity,
|
Complexity = source.Complexity,
|
||||||
AiTags = source.AiTags,
|
AiTags = source.AiTags,
|
||||||
AiPredictionId = source.AiPredictionId
|
AiPredictionId = source.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = source.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = source.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = source.FormulaFieldValuesJson
|
||||||
},
|
},
|
||||||
jobId,
|
jobId,
|
||||||
companyId,
|
companyId,
|
||||||
@@ -353,6 +362,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
Complexity = seed.Complexity,
|
Complexity = seed.Complexity,
|
||||||
AiTags = seed.AiTags,
|
AiTags = seed.AiTags,
|
||||||
AiPredictionId = seed.AiPredictionId,
|
AiPredictionId = seed.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = seed.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = seed.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = seed.FormulaFieldValuesJson,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
};
|
};
|
||||||
@@ -480,6 +492,9 @@ public class JobItemAssemblyService : IJobItemAssemblyService
|
|||||||
public string? Complexity { get; init; }
|
public string? Complexity { get; init; }
|
||||||
public string? AiTags { get; init; }
|
public string? AiTags { get; init; }
|
||||||
public int? AiPredictionId { get; init; }
|
public int? AiPredictionId { get; init; }
|
||||||
|
public bool IsCustomFormulaItem { get; init; }
|
||||||
|
public int? CustomItemTemplateId { get; init; }
|
||||||
|
public string? FormulaFieldValuesJson { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
/// <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.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||||
|
|
||||||
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
page.Header().Element(c => ComposeInvoiceHeader(c, companyLogo, companyInfo, accentColor, invoiceDto));
|
||||||
page.Content().Layers(layers =>
|
page.Content().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
||||||
{
|
|
||||||
layers.PrimaryLayer().Element(c => ComposeInvoiceContent(c, invoiceDto, accentColor, template));
|
|
||||||
if (invoiceDto.Status == InvoiceStatus.Paid)
|
|
||||||
layers.Layer().Element(c => ComposePaidStamp(c));
|
|
||||||
});
|
|
||||||
page.Footer().AlignCenter().Text(text =>
|
page.Footer().AlignCenter().Text(text =>
|
||||||
{
|
{
|
||||||
text.CurrentPageNumber();
|
text.CurrentPageNumber();
|
||||||
@@ -148,8 +143,18 @@ public class PdfService : IPdfService
|
|||||||
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
|
column.Item().Text(cityLine).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||||
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
|
column.Item().Text(FormatPhoneNumber(companyInfo.Phone)).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||||
|
if (!string.IsNullOrWhiteSpace(companyInfo.PrimaryContactEmail))
|
||||||
|
column.Item().Text(companyInfo.PrimaryContactEmail).FontSize(9).FontColor(Colors.Grey.Darken1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (invoice.Status == InvoiceStatus.Paid)
|
||||||
|
{
|
||||||
|
row.RelativeItem().AlignCenter().AlignMiddle()
|
||||||
|
.Border(2).BorderColor(Colors.Green.Darken1)
|
||||||
|
.PaddingVertical(6).PaddingHorizontal(16)
|
||||||
|
.Text("PAID").FontSize(20).Bold().FontColor(Colors.Green.Darken1).LetterSpacing(0.15f);
|
||||||
|
}
|
||||||
|
|
||||||
row.RelativeItem().AlignRight().Column(column =>
|
row.RelativeItem().AlignRight().Column(column =>
|
||||||
{
|
{
|
||||||
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor);
|
column.Item().Text("INVOICE").FontSize(28).Bold().FontColor(accentColor);
|
||||||
@@ -165,27 +170,6 @@ public class PdfService : IPdfService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Renders a semi-transparent angled PAID stamp centred over the invoice content layer.
|
|
||||||
/// Uses QuestPDF layout primitives (AlignCenter, AlignMiddle, Rotate, Opacity) so no
|
|
||||||
/// external Skia/SkiaSharp dependency is needed.
|
|
||||||
/// </summary>
|
|
||||||
private static void ComposePaidStamp(IContainer container)
|
|
||||||
{
|
|
||||||
container
|
|
||||||
.AlignCenter()
|
|
||||||
.AlignMiddle()
|
|
||||||
.Rotate(-45f)
|
|
||||||
.Border(5)
|
|
||||||
.BorderColor(Colors.Green.Darken2)
|
|
||||||
.PaddingVertical(14)
|
|
||||||
.PaddingHorizontal(28)
|
|
||||||
.Text("PAID")
|
|
||||||
.FontSize(80)
|
|
||||||
.Bold()
|
|
||||||
.FontColor(Colors.Green.Darken2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
/// Composes the body of the invoice PDF: bill-to address block, job reference, alternating-row
|
||||||
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
/// line-item table, and a right-aligned totals block that conditionally shows discount, tax,
|
||||||
@@ -217,6 +201,8 @@ public class PdfService : IPdfService
|
|||||||
c.Item().Text($"Job #: {invoice.JobNumber}");
|
c.Item().Text($"Job #: {invoice.JobNumber}");
|
||||||
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
if (!string.IsNullOrWhiteSpace(invoice.CustomerPO))
|
||||||
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
c.Item().Text($"PO #: {invoice.CustomerPO}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(invoice.ProjectName))
|
||||||
|
c.Item().Text($"Project: {invoice.ProjectName}");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,6 +595,15 @@ public class PdfService : IPdfService
|
|||||||
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
row.RelativeItem().Text(quote.CustomerPO).FontSize(9);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(quote.ProjectName))
|
||||||
|
{
|
||||||
|
column.Item().Row(row =>
|
||||||
|
{
|
||||||
|
row.ConstantItem(80).Text("Project:").FontSize(9);
|
||||||
|
row.RelativeItem().Text(quote.ProjectName).FontSize(9);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -220,6 +220,16 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when a coat requires ordering custom powder that is not in inventory.
|
||||||
|
/// Only coats with an explicit PowderToOrder quantity qualify — coats without a quantity
|
||||||
|
/// fall through to the standard surface-area pricing path in CalculateCoatPriceAsync.
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsCustomPowderCoat(CreateQuoteItemCoatDto coat) =>
|
||||||
|
!coat.InventoryItemId.HasValue &&
|
||||||
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
/// Calculates the total price for a single quote line item, routing to the correct pricing
|
||||||
/// path based on item type:
|
/// path based on item type:
|
||||||
@@ -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.
|
// Sales items (off-the-shelf merchandise) — manual unit price, no coating calculation.
|
||||||
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
if (item.IsSalesItem && item.ManualUnitPrice.HasValue)
|
||||||
{
|
{
|
||||||
@@ -312,6 +342,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
{
|
{
|
||||||
for (int i = 0; i < item.Coats.Count; i++)
|
for (int i = 0; i < item.Coats.Count; i++)
|
||||||
{
|
{
|
||||||
|
// Custom powder material moves to the "Custom Powder Order" line item
|
||||||
|
if (IsCustomPowderCoat(item.Coats[i])) continue;
|
||||||
var coatResult = await CalculateCoatPriceAsync(
|
var coatResult = await CalculateCoatPriceAsync(
|
||||||
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
item.Coats[i], 0m, item.Quantity, i, 0, companyId);
|
||||||
coatMaterialCost += coatResult.CoatMaterialCost;
|
coatMaterialCost += coatResult.CoatMaterialCost;
|
||||||
@@ -413,7 +445,9 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
for (int ci = 0; ci < item.Coats.Count; ci++)
|
for (int ci = 0; ci < item.Coats.Count; ci++)
|
||||||
{
|
{
|
||||||
var coat = item.Coats[ci];
|
var coat = item.Coats[ci];
|
||||||
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
// Custom powder with PowderToOrder moves to the "Custom Powder Order" line item; skip here
|
||||||
|
if (!coat.InventoryItemId.HasValue && coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0
|
||||||
|
&& !IsCustomPowderCoat(coat))
|
||||||
{
|
{
|
||||||
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
var coatResult = await CalculateCoatPriceAsync(coat, item.SurfaceAreaSqFt, item.Quantity, ci, 0, companyId);
|
||||||
totalMaterialCost += coatResult.CoatMaterialCost;
|
totalMaterialCost += coatResult.CoatMaterialCost;
|
||||||
@@ -431,7 +465,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
{
|
{
|
||||||
var firstCoatResult = await CalculateCoatPriceAsync(
|
var firstCoatResult = await CalculateCoatPriceAsync(
|
||||||
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
item.Coats[0], item.SurfaceAreaSqFt, item.Quantity, 0, item.EstimatedMinutes, companyId);
|
||||||
totalMaterialCost = firstCoatResult.CoatMaterialCost;
|
// Custom powder material moves to the "Custom Powder Order" line item; keep the labor
|
||||||
|
totalMaterialCost = IsCustomPowderCoat(item.Coats[0]) ? 0m : firstCoatResult.CoatMaterialCost;
|
||||||
coatLaborCost = firstCoatResult.CoatLaborCost;
|
coatLaborCost = firstCoatResult.CoatLaborCost;
|
||||||
totalLaborCost = coatLaborCost;
|
totalLaborCost = coatLaborCost;
|
||||||
}
|
}
|
||||||
@@ -628,6 +663,49 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
// 4. TOTAL ITEMS SUBTOTAL
|
// 4. TOTAL ITEMS SUBTOTAL
|
||||||
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
var itemsSubtotal = catalogItemsWithoutCoatsTotal + calculatedItemsSubtotal;
|
||||||
|
|
||||||
|
// Powder-to-order costs are excluded from individual item prices and collected in a
|
||||||
|
// "Custom Powder Order" line item added at save time. For live pricing previews (before
|
||||||
|
// save), add them back here so the displayed total stays correct throughout the session.
|
||||||
|
// Two coat types qualify: custom powder (no InventoryItemId, manual PowderCostPerLb) and
|
||||||
|
// incoming powder (InventoryItemId set, IsIncoming=true, cost from inventoryItem.UnitCost).
|
||||||
|
bool hasCustomPowderOrderItem = items.Any(i =>
|
||||||
|
i.IsGenericItem && i.Description?.StartsWith("Custom Powder Order") == true);
|
||||||
|
decimal customPowderOrderAmount = 0m;
|
||||||
|
var customPowderOrderColors = new List<string>();
|
||||||
|
if (!hasCustomPowderOrderItem)
|
||||||
|
{
|
||||||
|
foreach (var item in items.Where(i => i.Coats != null))
|
||||||
|
{
|
||||||
|
foreach (var c in item.Coats!)
|
||||||
|
{
|
||||||
|
if (!c.InventoryItemId.HasValue &&
|
||||||
|
c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0 &&
|
||||||
|
c.PowderCostPerLb.HasValue && c.PowderCostPerLb.Value > 0)
|
||||||
|
{
|
||||||
|
customPowderOrderAmount += c.PowderToOrder.Value * c.PowderCostPerLb.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(c.ColorName))
|
||||||
|
customPowderOrderColors.Add(c.ColorName);
|
||||||
|
}
|
||||||
|
else if (c.InventoryItemId.HasValue && c.PowderToOrder.HasValue && c.PowderToOrder.Value > 0)
|
||||||
|
{
|
||||||
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
||||||
|
if (invItem?.IsIncoming == true)
|
||||||
|
{
|
||||||
|
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
|
||||||
|
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
customPowderOrderColors.Add(colorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (customPowderOrderAmount > 0)
|
||||||
|
{
|
||||||
|
itemsSubtotal += customPowderOrderAmount;
|
||||||
|
totalMaterialCosts += customPowderOrderAmount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
// 4b. OVEN BATCH COST (quote-level: batches × cycle time × oven rate)
|
||||||
// AI items already have oven cost baked into their AI-estimated price, so we only
|
// AI items already have oven cost baked into their AI-estimated price, so we only
|
||||||
// charge the proportion of the oven that's attributable to non-AI items.
|
// charge the proportion of the oven that's attributable to non-AI items.
|
||||||
@@ -806,7 +884,11 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
MaterialCosts = Math.Round(totalMaterialCosts, 2),
|
||||||
LaborCosts = Math.Round(totalLaborCosts, 2),
|
LaborCosts = Math.Round(totalLaborCosts, 2),
|
||||||
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
EquipmentCosts = Math.Round(totalEquipmentCosts, 2),
|
||||||
ItemResults = itemResults
|
ItemResults = itemResults,
|
||||||
|
CustomPowderOrderAmount = Math.Round(customPowderOrderAmount, 2),
|
||||||
|
CustomPowderOrderColors = customPowderOrderColors
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||||
|
|
||||||
|
var dtoList = itemDtos.ToList();
|
||||||
var items = new List<QuoteItem>();
|
var items = new List<QuoteItem>();
|
||||||
foreach (var itemDto in itemDtos)
|
foreach (var itemDto in dtoList)
|
||||||
{
|
{
|
||||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||||
@@ -102,6 +103,17 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
items.Add(item);
|
items.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Option B: auto-create the Custom Powder Order item only on first save.
|
||||||
|
// Once user-owned, they manage its price (e.g. to add shipping) — we never overwrite it.
|
||||||
|
bool hasExistingCustomPowderOrder = dtoList.Any(d =>
|
||||||
|
d.IsGenericItem && d.Description?.StartsWith("Custom Powder Order") == true);
|
||||||
|
if (!hasExistingCustomPowderOrder)
|
||||||
|
{
|
||||||
|
var customPowderItem = await BuildCustomPowderOrderItemAsync(dtoList, quoteId, companyId, createdAtUtc);
|
||||||
|
if (customPowderItem != null)
|
||||||
|
items.Add(customPowderItem);
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +142,14 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (itemDto.IsCustomFormulaItem && itemDto.ManualUnitPrice.HasValue)
|
||||||
|
{
|
||||||
|
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||||
|
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||||
|
_logger.LogInformation("Custom formula item (FixedRate) price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (itemDto.CatalogItemId.HasValue)
|
if (itemDto.CatalogItemId.HasValue)
|
||||||
{
|
{
|
||||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||||
@@ -161,9 +181,10 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||||
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
/// When a coat references the platform catalog (<c>CatalogItemId</c> set), the ID is stored on
|
||||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
/// <see cref="QuoteItemCoat.PowderCatalogItemId"/> so that at <em>approval</em> time the system
|
||||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
/// can create exactly one <see cref="InventoryItem"/> per unique powder across all coats on the
|
||||||
|
/// quote (deduplication). No inventory is created during quote save.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||||
{
|
{
|
||||||
@@ -175,8 +196,8 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
{
|
{
|
||||||
var coatDto = itemDto.Coats[coatIndex];
|
var coatDto = itemDto.Coats[coatIndex];
|
||||||
|
|
||||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
// Incoming-inventory creation is intentionally deferred to quote approval.
|
||||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
// PowderCatalogItemId is persisted on the coat entity for later use.
|
||||||
|
|
||||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||||
@@ -243,6 +264,9 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
IsAiItem = itemDto.IsAiItem,
|
IsAiItem = itemDto.IsAiItem,
|
||||||
AiTags = itemDto.AiTags,
|
AiTags = itemDto.AiTags,
|
||||||
AiPredictionId = itemDto.AiPredictionId,
|
AiPredictionId = itemDto.AiPredictionId,
|
||||||
|
IsCustomFormulaItem = itemDto.IsCustomFormulaItem,
|
||||||
|
CustomItemTemplateId = itemDto.CustomItemTemplateId,
|
||||||
|
FormulaFieldValuesJson = itemDto.FormulaFieldValuesJson,
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
CreatedAt = createdAtUtc
|
CreatedAt = createdAtUtc
|
||||||
};
|
};
|
||||||
@@ -256,6 +280,7 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
CoatName = coatDto.CoatName,
|
CoatName = coatDto.CoatName,
|
||||||
Sequence = coatDto.Sequence,
|
Sequence = coatDto.Sequence,
|
||||||
InventoryItemId = coatDto.InventoryItemId,
|
InventoryItemId = coatDto.InventoryItemId,
|
||||||
|
PowderCatalogItemId = coatDto.CatalogItemId,
|
||||||
ColorName = coatDto.ColorName,
|
ColorName = coatDto.ColorName,
|
||||||
VendorId = coatDto.VendorId,
|
VendorId = coatDto.VendorId,
|
||||||
ColorCode = coatDto.ColorCode,
|
ColorCode = coatDto.ColorCode,
|
||||||
@@ -305,34 +330,36 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
/// Creates one "incoming" <see cref="InventoryItem"/> from a platform catalog entry.
|
||||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
/// Called at quote-approval time (not during quote save) so inventory records only appear
|
||||||
|
/// when a job is actually going to be created. The caller groups coats by
|
||||||
|
/// <c>PowderCatalogItemId</c> and calls this once per unique catalog item, preventing
|
||||||
|
/// duplicate records when the same powder appears on multiple items in the same quote.
|
||||||
///
|
///
|
||||||
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
/// Category resolution prefers the company's "POWDER" category (CategoryCode=="POWDER")
|
||||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
/// so the item always lands in the right bucket regardless of how many IsCoating categories
|
||||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
/// the company has defined. Falls back to the lowest-DisplayOrder IsCoating category.
|
||||||
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
|
||||||
///
|
///
|
||||||
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
/// AI augmentation fills in missing technical specs (cure temp/time, coverage, color families)
|
||||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
/// from the manufacturer product page. Best-effort — item is still created from catalog data
|
||||||
/// if it fails, the item is still created with whatever data the catalog has.
|
/// if the AI call fails.
|
||||||
///
|
|
||||||
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
|
|
||||||
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
|
|
||||||
/// inventory unit cost rather than the now-stale manual price from the quote form.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
private async Task<int?> CreateIncomingInventoryItemAsync(int catalogItemId, int companyId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(catalogItemId);
|
||||||
if (catalogItem == null) return null;
|
if (catalogItem == null) return null;
|
||||||
|
|
||||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||||
var coatingCategory = categories
|
// Prefer the canonical "POWDER" category so catalog-sourced items never land in an
|
||||||
.Where(c => c.IsActive && c.IsCoating)
|
// unrelated coating category (e.g. "Cerakote") that happens to have IsCoating=true.
|
||||||
.OrderBy(c => c.DisplayOrder)
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
.FirstOrDefault();
|
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories
|
||||||
|
.Where(c => c.IsActive && c.IsCoating)
|
||||||
|
.OrderBy(c => c.DisplayOrder)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||||
@@ -437,17 +464,143 @@ public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
|||||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||||
await _unitOfWork.SaveChangesAsync();
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
|
||||||
coatDto.PowderCostPerLb = null;
|
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} at quote approval",
|
||||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
item.Id, item.Name, catalogItemId);
|
||||||
item.Id, item.Name, coatDto.CatalogItemId);
|
|
||||||
|
|
||||||
return item.Id;
|
return item.Id;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||||
coatDto.CatalogItemId);
|
catalogItemId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans all coat DTOs for powder that must be ordered (custom or catalog-sourced) and returns a
|
||||||
|
/// single "Custom Powder Order" QuoteItem aggregating all material costs and color names.
|
||||||
|
/// Returns null when no such coats are found. Used by <see cref="CreateQuoteItemsAsync"/>
|
||||||
|
/// on the first save only — Option B means the user owns the price after creation.
|
||||||
|
///
|
||||||
|
/// Coat types that qualify:
|
||||||
|
/// - Custom powder: no InventoryItemId, manual PowderCostPerLb > 0 (user-entered)
|
||||||
|
/// - Catalog-sourced pending incoming: CatalogItemId set, no InventoryItemId, PowderCostPerLb
|
||||||
|
/// pre-filled from catalog unit price (inventory creation deferred to approval)
|
||||||
|
/// - Legacy path: InventoryItemId set and item.IsIncoming == true (pre-fix records)
|
||||||
|
/// </summary>
|
||||||
|
private async Task<QuoteItem?> BuildCustomPowderOrderItemAsync(
|
||||||
|
IReadOnlyList<CreateQuoteItemDto> itemDtos, int quoteId, int companyId, DateTime createdAtUtc)
|
||||||
|
{
|
||||||
|
var colorNames = new List<string>();
|
||||||
|
decimal totalCost = 0m;
|
||||||
|
|
||||||
|
foreach (var itemDto in itemDtos)
|
||||||
|
{
|
||||||
|
if (itemDto.Coats == null) continue;
|
||||||
|
foreach (var coat in itemDto.Coats)
|
||||||
|
{
|
||||||
|
if (!coat.InventoryItemId.HasValue &&
|
||||||
|
coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0 &&
|
||||||
|
coat.PowderCostPerLb.HasValue && coat.PowderCostPerLb.Value > 0)
|
||||||
|
{
|
||||||
|
// Custom powder (manual cost) or catalog-sourced incoming (cost pre-filled from catalog).
|
||||||
|
// Both arrive here the same way: PowderCostPerLb set, no inventory link yet.
|
||||||
|
totalCost += coat.PowderToOrder.Value * coat.PowderCostPerLb.Value;
|
||||||
|
if (!string.IsNullOrWhiteSpace(coat.ColorName))
|
||||||
|
colorNames.Add(coat.ColorName);
|
||||||
|
}
|
||||||
|
else if (coat.InventoryItemId.HasValue && coat.PowderToOrder.HasValue && coat.PowderToOrder.Value > 0)
|
||||||
|
{
|
||||||
|
// Legacy path: inventory was already created (quotes saved before the deferred-creation fix).
|
||||||
|
// PowderCostPerLb was cleared on those coats so cost must come from inventory.
|
||||||
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||||
|
if (invItem?.IsIncoming == true)
|
||||||
|
{
|
||||||
|
totalCost += coat.PowderToOrder.Value * invItem.UnitCost;
|
||||||
|
var colorName = !string.IsNullOrWhiteSpace(coat.ColorName) ? coat.ColorName : invItem.Name;
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
colorNames.Add(colorName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (totalCost <= 0) return null;
|
||||||
|
|
||||||
|
var uniqueColors = colorNames
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
var description = uniqueColors.Any()
|
||||||
|
? $"Custom Powder Order ({string.Join(", ", uniqueColors)})"
|
||||||
|
: "Custom Powder Order";
|
||||||
|
|
||||||
|
return new QuoteItem
|
||||||
|
{
|
||||||
|
QuoteId = quoteId,
|
||||||
|
Description = description,
|
||||||
|
Quantity = 1,
|
||||||
|
IsGenericItem = true,
|
||||||
|
ManualUnitPrice = totalCost,
|
||||||
|
UnitPrice = totalCost,
|
||||||
|
TotalPrice = totalCost,
|
||||||
|
ItemMaterialCost = totalCost,
|
||||||
|
CompanyId = companyId,
|
||||||
|
CreatedAt = createdAtUtc,
|
||||||
|
Coats = [],
|
||||||
|
PrepServices = []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Called at quote approval time to create exactly one <see cref="InventoryItem"/> per unique
|
||||||
|
/// powder catalog entry referenced across all coats on the quote, then links each coat to its
|
||||||
|
/// new (or existing) inventory record.
|
||||||
|
///
|
||||||
|
/// WHY deferred: during quoting the job may never be approved, so creating inventory records at
|
||||||
|
/// quote-save time produces orphaned, never-ordered items. Deferring to approval ensures inventory
|
||||||
|
/// only reflects powders the shop is actually going to process.
|
||||||
|
///
|
||||||
|
/// Deduplication: multiple items on the same quote that use the same catalog powder receive the
|
||||||
|
/// same InventoryItemId — no duplicate records are created.
|
||||||
|
///
|
||||||
|
/// Idempotent: coats that already have an InventoryItemId are skipped, so calling this method
|
||||||
|
/// on an already-approved quote (e.g. retry after a transient error) is safe.
|
||||||
|
/// </summary>
|
||||||
|
public async Task EnsureIncomingInventoryForApprovedQuoteAsync(int quoteId, int companyId)
|
||||||
|
{
|
||||||
|
// Load all QuoteItems for this quote with their coats so we can inspect PowderCatalogItemId.
|
||||||
|
var quoteItems = await _unitOfWork.QuoteItems.FindAsync(
|
||||||
|
qi => qi.QuoteId == quoteId && qi.CompanyId == companyId,
|
||||||
|
false,
|
||||||
|
qi => qi.Coats);
|
||||||
|
|
||||||
|
var pendingCoats = quoteItems
|
||||||
|
.SelectMany(qi => qi.Coats)
|
||||||
|
.Where(c => c.PowderCatalogItemId.HasValue && !c.InventoryItemId.HasValue)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (pendingCoats.Count == 0) return;
|
||||||
|
|
||||||
|
// Group by catalog item ID so each unique powder generates exactly one inventory record.
|
||||||
|
var groups = pendingCoats
|
||||||
|
.GroupBy(c => c.PowderCatalogItemId!.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var group in groups)
|
||||||
|
{
|
||||||
|
var newInventoryId = await CreateIncomingInventoryItemAsync(group.Key, companyId);
|
||||||
|
if (newInventoryId == null) continue;
|
||||||
|
|
||||||
|
// Link every coat in this group to the single newly-created inventory record.
|
||||||
|
foreach (var coat in group)
|
||||||
|
{
|
||||||
|
coat.InventoryItemId = newInventoryId;
|
||||||
|
coat.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.QuoteItemCoats.UpdateAsync(coat);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _unitOfWork.SaveChangesAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,145 +5,165 @@ namespace PowderCoating.Application.Services;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
/// Derives sqft/hr throughput rates from a shop's equipment configuration.
|
||||||
/// Used in two places: the AI photo quote prompt (so Claude reasons from real shop
|
/// Used by the AI photo quote prompt (so Claude reasons from real shop speeds)
|
||||||
/// speeds) and the calculated-item wizard (to show a suggested blast time hint).
|
/// and the Company Settings live preview (so the UI always shows the same rate
|
||||||
|
/// the AI will use — single formula path, no client-side duplication).
|
||||||
///
|
///
|
||||||
/// Formula:
|
/// Both pressure pots and siphon cabinets are nozzle-primary: nozzle size
|
||||||
/// BlastRate = BaseByCfm(cfm) × NozzleMultiplier × SetupMultiplier × SubstrateMultiplier
|
/// determines throughput and CFM draw. CFM is not used in the rate formula.
|
||||||
///
|
///
|
||||||
/// Base rates by CFM represent a pressure pot at #5 nozzle removing paint.
|
/// Sources:
|
||||||
/// All multipliers are relative to that baseline.
|
/// Pressure pot rates — averaged from two industry standard abrasive blast
|
||||||
|
/// cleaning reference tables.
|
||||||
|
/// Siphon cabinet rates — industry reference table for siphon-fed cabinets.
|
||||||
|
/// Substrate multipliers — relative removal difficulty vs. paint baseline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class ShopCapabilityCalculator
|
public static class ShopCapabilityCalculator
|
||||||
{
|
{
|
||||||
// ── Blast rate derivation ─────────────────────────────────────────────────
|
// ── Public entry points ────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr.
|
/// Returns the effective blast rate in sqft/hr for company-level operating costs.
|
||||||
/// If <see cref="CompanyOperatingCosts.BlastRateSqFtPerHourOverride"/> is set, returns it directly.
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// Otherwise derives from CFM, nozzle, setup type, and substrate.
|
|
||||||
/// Returns 0 when CFM is not configured (shop hasn't calibrated yet).
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetBlastRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
if (costs.BlastRateSqFtPerHourOverride.HasValue && costs.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.BlastRateSqFtPerHourOverride.Value;
|
return costs.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (costs.CompressorCfm <= 0)
|
return CalculateBlastRate(costs.BlastNozzleSize, costs.BlastSetupType, costs.PrimaryBlastSubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(costs.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(costs.BlastNozzleSize);
|
|
||||||
var setup = SetupMultiplier(costs.BlastSetupType);
|
|
||||||
var substrate = SubstrateMultiplier(costs.PrimaryBlastSubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setup * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective blast rate in sqft/hr for a named <see cref="CompanyBlastSetup"/>.
|
/// Returns the effective blast rate in sqft/hr for a named blast setup.
|
||||||
/// Identical logic to the <see cref="CompanyOperatingCosts"/> overload — uses override if set,
|
/// BlastRateSqFtPerHourOverride bypasses the formula when set.
|
||||||
/// otherwise derives from the setup's equipment specs.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
public static decimal GetBlastRateSqFtPerHour(CompanyBlastSetup setup)
|
||||||
{
|
{
|
||||||
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
if (setup.BlastRateSqFtPerHourOverride.HasValue && setup.BlastRateSqFtPerHourOverride.Value > 0)
|
||||||
return setup.BlastRateSqFtPerHourOverride.Value;
|
return setup.BlastRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
if (setup.CompressorCfm <= 0)
|
return CalculateBlastRate(setup.BlastNozzleSize, setup.SetupType, setup.PrimarySubstrate);
|
||||||
return 0m;
|
|
||||||
|
|
||||||
var baseRate = BaseByCfm(setup.CompressorCfm);
|
|
||||||
var nozzle = NozzleMultiplier(setup.BlastNozzleSize);
|
|
||||||
var setupMult = SetupMultiplier(setup.SetupType);
|
|
||||||
var substrate = SubstrateMultiplier(setup.PrimarySubstrate);
|
|
||||||
|
|
||||||
return Math.Round(baseRate * nozzle * setupMult * substrate, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the effective coating application rate in sqft/hr.
|
/// Returns the effective coating application rate in sqft/hr.
|
||||||
/// If override is set, returns it directly.
|
/// Override bypasses the formula when set.
|
||||||
/// Otherwise derives a sensible default from gun type.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
public static decimal GetCoatingRateSqFtPerHour(CompanyOperatingCosts costs)
|
||||||
{
|
{
|
||||||
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
if (costs.CoatingRateSqFtPerHourOverride.HasValue && costs.CoatingRateSqFtPerHourOverride.Value > 0)
|
||||||
return costs.CoatingRateSqFtPerHourOverride.Value;
|
return costs.CoatingRateSqFtPerHourOverride.Value;
|
||||||
|
|
||||||
// Corona and tribo guns are roughly similar on flat parts; tribo edges out on complex geometry.
|
|
||||||
// Without more equipment data (voltage, gun model) we use a single reasonable default.
|
|
||||||
return costs.CoatingGunType switch
|
return costs.CoatingGunType switch
|
||||||
{
|
{
|
||||||
CoatingGunType.Corona => 40m,
|
CoatingGunType.Corona => 40m,
|
||||||
CoatingGunType.Tribo => 35m, // slower on flat but better on complex; conservative default
|
CoatingGunType.Tribo => 35m,
|
||||||
CoatingGunType.Both => 40m,
|
CoatingGunType.Both => 40m,
|
||||||
_ => 40m
|
_ => 40m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns default equipment field values for a given capability tier.
|
/// Returns default equipment field values for a given capability tier, applied
|
||||||
/// Applied during Setup Wizard tier selection so the shop gets reasonable
|
/// during Setup Wizard tier selection so new shops get reasonable starting values.
|
||||||
/// starting values even if they never visit the Quoting Calibration tab.
|
/// CFM defaults reflect typical compressor sizes for each tier; they appear in the
|
||||||
|
/// UI for reference but are not used in the rate formula.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
public static (BlastSetupType SetupType, decimal Cfm, int NozzleSize, BlastSubstrateType Substrate)
|
||||||
TierDefaults(ShopCapabilityTier tier) => tier switch
|
TierDefaults(ShopCapabilityTier tier) => tier switch
|
||||||
{
|
{
|
||||||
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Garage => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 40m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Small => (BlastSetupType.PressurePot, 49m, 3, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 80m, 5, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Medium => (BlastSetupType.PressurePot, 90m, 4, BlastSubstrateType.Mixed),
|
||||||
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 6, BlastSubstrateType.Mixed),
|
ShopCapabilityTier.Large => (BlastSetupType.PressurePot, 150m, 5, BlastSubstrateType.Mixed),
|
||||||
_ => (BlastSetupType.SiphonCabinet, 7m, 4, BlastSubstrateType.Mixed)
|
_ => (BlastSetupType.SiphonCabinet, 7m, 3, BlastSubstrateType.Mixed)
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Private helpers ───────────────────────────────────────────────────────
|
// ── Core formula (single path for all callers) ─────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Base sqft/hr at a pressure pot, #5 nozzle, removing paint.
|
/// Nozzle-primary blast rate calculation. Nozzle size determines throughput;
|
||||||
/// Calibrated so that real-world examples produce expected results:
|
/// setup type routes to the appropriate reference table; substrate adjusts for
|
||||||
/// - 7 CFM siphon cabinet → ~2 sqft/hr (garage coater, 3+ hrs/wheel)
|
/// removal difficulty. CFM is not used — it is a consequence of nozzle choice,
|
||||||
/// - 40 CFM pressure pot → ~15 sqft/hr (small shop, ~30 min/wheel)
|
/// not an independent variable in throughput.
|
||||||
/// - 80 CFM pressure pot → ~25 sqft/hr (medium shop)
|
|
||||||
/// - 150 CFM pressure pot → ~40 sqft/hr (large shop, ~10 min/wheel)
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static decimal BaseByCfm(decimal cfm) => cfm switch
|
private static decimal CalculateBlastRate(int nozzle, BlastSetupType setupType, BlastSubstrateType substrate)
|
||||||
{
|
{
|
||||||
< 10 => 5m,
|
var baseRate = setupType switch
|
||||||
< 20 => 9m,
|
{
|
||||||
< 40 => 15m,
|
BlastSetupType.PressurePot => PressurePotRateByNozzle(nozzle),
|
||||||
< 80 => 25m,
|
BlastSetupType.SiphonCabinet => SiphonCabinetRateByNozzle(nozzle),
|
||||||
< 120 => 35m,
|
// Siphon pot: open gravity feed, no enclosure penalty, ~80% of pressure pot
|
||||||
_ => 45m
|
BlastSetupType.SiphonPot => Math.Round(PressurePotRateByNozzle(nozzle) * 0.80m, 1),
|
||||||
|
// Wet blasting: water-media mix reduces impact velocity, ~60% of dry pressure pot
|
||||||
|
BlastSetupType.WetBlasting => Math.Round(PressurePotRateByNozzle(nozzle) * 0.60m, 1),
|
||||||
|
_ => 0m
|
||||||
|
};
|
||||||
|
|
||||||
|
return Math.Round(baseRate * SubstrateMultiplier(substrate), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Midpoint cleaning rates for a pressure pot at adequate air supply, by nozzle size.
|
||||||
|
/// Averaged from two industry-standard abrasive blast cleaning reference tables.
|
||||||
|
/// #1 (1/16"): 20-35 sqft/hr avg → 20
|
||||||
|
/// #2 (1/8"): 40-60 sqft/hr avg → 40
|
||||||
|
/// #3 (3/16"): 60-85 sqft/hr avg → 75
|
||||||
|
/// #4 (1/4"): 90-110 sqft/hr avg → 115
|
||||||
|
/// #5 (5/16"): 130-160 sqft/hr avg → 175
|
||||||
|
/// #6 (3/8"): 180-230 sqft/hr avg → 245
|
||||||
|
/// #7 (7/16"): 240-300 sqft/hr avg → 325
|
||||||
|
/// #8 (1/2"): 320-400 sqft/hr avg → 430
|
||||||
|
/// </summary>
|
||||||
|
private static decimal PressurePotRateByNozzle(int nozzle) => nozzle switch
|
||||||
|
{
|
||||||
|
1 => 20m,
|
||||||
|
2 => 40m,
|
||||||
|
3 => 75m,
|
||||||
|
4 => 115m,
|
||||||
|
5 => 175m,
|
||||||
|
6 => 245m,
|
||||||
|
7 => 325m,
|
||||||
|
8 => 430m,
|
||||||
|
_ => 100m
|
||||||
};
|
};
|
||||||
|
|
||||||
private static decimal NozzleMultiplier(int nozzle) => nozzle switch
|
/// <summary>
|
||||||
|
/// Midpoint cleaning rates for siphon-fed blast cabinets, by nozzle size.
|
||||||
|
/// Source: industry reference table for siphon cabinet production rates.
|
||||||
|
/// #1 (1/16"): 10-25 sqft/hr → 18
|
||||||
|
/// #2 (1/8"): 25-50 sqft/hr → 38
|
||||||
|
/// #3 (3/16"): 50-100 sqft/hr → 75
|
||||||
|
/// #4 (1/4"): 100-150 sqft/hr → 125
|
||||||
|
/// #5 (5/16"): 150-225 sqft/hr → 188
|
||||||
|
/// #6 (3/8"): 225-300 sqft/hr → 263
|
||||||
|
/// #7 (7/16"): 300-375 sqft/hr → 338
|
||||||
|
/// #8 (1/2"): 375-450 sqft/hr → 413
|
||||||
|
/// </summary>
|
||||||
|
private static decimal SiphonCabinetRateByNozzle(int nozzle) => nozzle switch
|
||||||
{
|
{
|
||||||
2 => 0.35m,
|
1 => 18m,
|
||||||
3 => 0.55m,
|
2 => 38m,
|
||||||
4 => 0.75m,
|
3 => 75m,
|
||||||
5 => 1.00m,
|
4 => 125m,
|
||||||
6 => 1.30m,
|
5 => 188m,
|
||||||
7 => 1.65m,
|
6 => 263m,
|
||||||
8 => 2.00m,
|
7 => 338m,
|
||||||
_ => 1.00m
|
8 => 413m,
|
||||||
};
|
_ => 80m
|
||||||
|
|
||||||
private static decimal SetupMultiplier(BlastSetupType setup) => setup switch
|
|
||||||
{
|
|
||||||
BlastSetupType.SiphonCabinet => 0.50m, // enclosed, low pressure, repositioning time
|
|
||||||
BlastSetupType.SiphonPot => 0.70m,
|
|
||||||
BlastSetupType.PressurePot => 1.00m, // baseline
|
|
||||||
BlastSetupType.WetBlasting => 0.60m,
|
|
||||||
_ => 1.00m
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adjustment for substrate removal difficulty relative to paint (baseline = 1.0).
|
||||||
|
/// Powder coat strips faster than paint; rust and scale requires multiple passes.
|
||||||
|
/// </summary>
|
||||||
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
private static decimal SubstrateMultiplier(BlastSubstrateType substrate) => substrate switch
|
||||||
{
|
{
|
||||||
BlastSubstrateType.PowderCoat => 1.25m, // faster to remove than paint
|
BlastSubstrateType.PowderCoat => 1.25m,
|
||||||
BlastSubstrateType.Paint => 1.00m, // baseline
|
BlastSubstrateType.Paint => 1.00m,
|
||||||
BlastSubstrateType.Mixed => 0.90m,
|
BlastSubstrateType.Mixed => 0.90m,
|
||||||
BlastSubstrateType.RustAndScale => 0.70m, // requires more passes
|
BlastSubstrateType.RustAndScale => 0.70m,
|
||||||
_ => 0.90m
|
_ => 0.90m
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ public class ApplicationUser : IdentityUser
|
|||||||
// Passkey enrollment prompt
|
// Passkey enrollment prompt
|
||||||
public bool PasskeyPromptDismissed { get; set; } = false;
|
public bool PasskeyPromptDismissed { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>BCrypt hash of the employee's 4-digit kiosk PIN. Null means kiosk timeclock is disabled for this user.</summary>
|
||||||
|
public string? KioskPin { get; set; }
|
||||||
|
|
||||||
// Ban
|
// Ban
|
||||||
public bool IsBanned { get; set; } = false;
|
public bool IsBanned { get; set; } = false;
|
||||||
public DateTime? BannedAt { get; set; }
|
public DateTime? BannedAt { get; set; }
|
||||||
|
|||||||
@@ -133,6 +133,15 @@ public class Company : BaseEntity
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? KioskActivationToken { get; set; }
|
public string? KioskActivationToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timeclock feature enabled for this company. When false, the nav link, dashboard, and reports are hidden.</summary>
|
||||||
|
public bool TimeclockEnabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>When true, employees can clock in/out multiple times per day (lunch breaks, etc.). When false, only one in/out pair is allowed per day.</summary>
|
||||||
|
public bool TimeclockAllowMultiplePunchesPerDay { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>If set, any open clock entry older than this many hours is automatically closed on the next clock-in. Null = no auto clock-out.</summary>
|
||||||
|
public int? TimeclockAutoClockOutHours { get; set; }
|
||||||
|
|
||||||
// Navigation Properties
|
// Navigation Properties
|
||||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||||
|
|||||||
@@ -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 bool IsActive { get; set; } = true;
|
||||||
public DateTime? LastContactDate { get; set; }
|
public DateTime? LastContactDate { get; set; }
|
||||||
|
|
||||||
|
// CRM fields
|
||||||
|
/// <summary>How the customer found the shop (Walk-In, Google Search, Customer Referral, etc.).</summary>
|
||||||
|
public string? LeadSource { get; set; }
|
||||||
|
|
||||||
|
// Ship-to / alternate address (separate from billing address above)
|
||||||
|
public string? ShipToAddress { get; set; }
|
||||||
|
public string? ShipToCity { get; set; }
|
||||||
|
public string? ShipToState { get; set; }
|
||||||
|
public string? ShipToZipCode { get; set; }
|
||||||
|
public string? ShipToCountry { get; set; }
|
||||||
|
|
||||||
// Notification preferences
|
// Notification preferences
|
||||||
public bool NotifyByEmail { get; set; } = true;
|
public bool NotifyByEmail { get; set; } = true;
|
||||||
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
// NotifyBySms is only set to true after explicit staff-recorded consent (TCPA compliance)
|
||||||
@@ -55,4 +66,5 @@ public class Customer : BaseEntity
|
|||||||
|
|
||||||
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
public virtual ICollection<NotificationLog> NotificationLogs { get; set; } = new List<NotificationLog>();
|
||||||
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
public virtual ICollection<Invoice> Invoices { get; set; } = new List<Invoice>();
|
||||||
|
public virtual ICollection<CustomerContact> CustomerContacts { get; set; } = new List<CustomerContact>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An additional contact person associated with a customer account.
|
||||||
|
/// Commercial customers frequently have separate billing, operations, and drop-off contacts.
|
||||||
|
/// The primary contact remains on the Customer entity; these are supplementary.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerContact : BaseEntity
|
||||||
|
{
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? LastName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Job title / role at the company, e.g. "Purchasing Manager".</summary>
|
||||||
|
[StringLength(100)]
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Functional role: Billing, Operations, Drop-off, Sales, General, etc.</summary>
|
||||||
|
[StringLength(50)]
|
||||||
|
public string? ContactRole { get; set; }
|
||||||
|
|
||||||
|
[StringLength(200)]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
[StringLength(20)]
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
[StringLength(20)]
|
||||||
|
public string? MobilePhone { get; set; }
|
||||||
|
|
||||||
|
[StringLength(500)]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public virtual Customer? Customer { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Facility-level clock-in/clock-out record for an employee.
|
||||||
|
/// Tracks when an employee arrives and leaves the facility — separate from JobTimeEntry which tracks
|
||||||
|
/// hours against a specific job. Multiple entries per day are fully supported (lunch breaks, etc.).
|
||||||
|
/// The only enforced constraint: a user may not have more than one open entry (ClockOutTime == null) at a time.
|
||||||
|
/// </summary>
|
||||||
|
public class EmployeeClockEntry : BaseEntity
|
||||||
|
{
|
||||||
|
public string UserId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public DateTime ClockInTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Null means the employee is currently clocked in.</summary>
|
||||||
|
public DateTime? ClockOutTime { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Stored at clock-out time: (ClockOutTime - ClockInTime) in hours, rounded to 2 decimal places.</summary>
|
||||||
|
public decimal? HoursWorked { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether this segment is regular work time, a break, or a lunch period.
|
||||||
|
/// Only <see cref="ClockEntryType.Work"/> entries count toward paid-hours totals.
|
||||||
|
/// </summary>
|
||||||
|
public ClockEntryType EntryType { get; set; } = ClockEntryType.Work;
|
||||||
|
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public virtual ApplicationUser User { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public class Equipment : BaseEntity
|
|||||||
public string? Location { get; set; }
|
public string? Location { get; set; }
|
||||||
|
|
||||||
// Maintenance Information
|
// Maintenance Information
|
||||||
public int RecommendedMaintenanceIntervalDays { get; set; }
|
public int? RecommendedMaintenanceIntervalDays { get; set; }
|
||||||
public DateTime? LastMaintenanceDate { get; set; }
|
public DateTime? LastMaintenanceDate { get; set; }
|
||||||
public DateTime? NextScheduledMaintenance { get; set; }
|
public DateTime? NextScheduledMaintenance { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records that a company imported a specific FormulaLibraryItem into their local template library.
|
||||||
|
/// Tenant-scoped via BaseEntity.CompanyId. One row per (company, library item) — re-importing the
|
||||||
|
/// same item overwrites the existing row rather than creating a duplicate.
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryImport : BaseEntity
|
||||||
|
{
|
||||||
|
public int FormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||||
|
|
||||||
|
public string ImportedByUserId { get; set; } = string.Empty;
|
||||||
|
public DateTime ImportedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>The CustomItemTemplate row created in this company's local library on import.</summary>
|
||||||
|
public int ResultingCustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate ResultingCustomItemTemplate { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Platform-level community library entry for a shared custom formula template.
|
||||||
|
/// Not tenant-scoped — no BaseEntity, no CompanyId, no soft delete.
|
||||||
|
/// Shared voluntarily by the originating company; imported as independent copies by others.
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryItem
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
// ── Formula content (copied from CustomItemTemplate at share time) ─────
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"FixedRate" or "SurfaceAreaSqFt" — mirrors CustomItemTemplate.OutputMode.</summary>
|
||||||
|
public string OutputMode { get; set; } = "FixedRate";
|
||||||
|
|
||||||
|
/// <summary>JSON array of field definitions: [{name, label, unit, defaultValue}]</summary>
|
||||||
|
public string FieldsJson { get; set; } = "[]";
|
||||||
|
|
||||||
|
/// <summary>NCalc expression using field name slugs and the reserved variable 'rate'.</summary>
|
||||||
|
public string Formula { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public decimal? DefaultRate { get; set; }
|
||||||
|
public string? RateLabel { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Blob path referencing the source template's diagram image.
|
||||||
|
/// Nulled out (here and on all imports) if the source template's diagram is removed.
|
||||||
|
/// </summary>
|
||||||
|
public string? DiagramImagePath { get; set; }
|
||||||
|
|
||||||
|
// ── Attribution ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Comma-separated community tags, e.g. "HVAC,Sheet Metal".</summary>
|
||||||
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional industry hint shown on the browse card, e.g. "HVAC", "Automotive".</summary>
|
||||||
|
public string? IndustryHint { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Id of the CustomItemTemplate this was shared from.</summary>
|
||||||
|
public int SourceCustomItemTemplateId { get; set; }
|
||||||
|
|
||||||
|
public int SourceCompanyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Denormalized company name so it renders without a join when the company is gone.</summary>
|
||||||
|
public string SourceCompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When non-null, this entry was derived from an imported formula that was subsequently
|
||||||
|
/// modified. Points to the original library entry. Shown as "Inspired by..." on the browse card.
|
||||||
|
/// </summary>
|
||||||
|
public int? InspiredByFormulaLibraryItemId { get; set; }
|
||||||
|
public virtual FormulaLibraryItem? InspiredBy { get; set; }
|
||||||
|
|
||||||
|
public string SharedByUserId { get; set; } = string.Empty;
|
||||||
|
public DateTime SharedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>False when the creator has removed it from the community library.</summary>
|
||||||
|
public bool IsPublished { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Running count of how many companies have imported this entry.</summary>
|
||||||
|
public int ImportCount { get; set; }
|
||||||
|
|
||||||
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
public DateTime? UpdatedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One thumbs-up or thumbs-down vote per company per library formula.
|
||||||
|
/// Platform-level — no BaseEntity, no soft delete, no CompanyId tenant filter.
|
||||||
|
/// Unique constraint enforced at the DB level: (FormulaLibraryItemId, CompanyId).
|
||||||
|
/// </summary>
|
||||||
|
public class FormulaLibraryRating
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public int FormulaLibraryItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The company casting the vote.</summary>
|
||||||
|
public int CompanyId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>True = thumbs up, false = thumbs down.</summary>
|
||||||
|
public bool IsPositive { get; set; }
|
||||||
|
|
||||||
|
public DateTime RatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
public virtual FormulaLibraryItem FormulaLibraryItem { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -12,4 +12,5 @@ public class InventoryCategoryLookup : BaseEntity
|
|||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||||
|
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ public class Invoice : BaseEntity
|
|||||||
public string? InternalNotes { get; set; }
|
public string? InternalNotes { get; set; }
|
||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ public class Job : BaseEntity
|
|||||||
|
|
||||||
// Additional Information
|
// Additional Information
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? SpecialInstructions { get; set; }
|
public string? SpecialInstructions { get; set; }
|
||||||
public string? InternalNotes { get; set; } // Internal notes from quote
|
public string? InternalNotes { get; set; } // Internal notes from quote
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|||||||
@@ -52,6 +52,14 @@ public class JobItem : BaseEntity
|
|||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual Job Job { get; set; } = null!;
|
public virtual Job Job { get; set; } = null!;
|
||||||
public virtual CatalogItem? CatalogItem { get; set; }
|
public virtual CatalogItem? CatalogItem { get; set; }
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ public class Quote : BaseEntity
|
|||||||
public string? Terms { get; set; }
|
public string? Terms { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public string? CustomerPO { get; set; }
|
public string? CustomerPO { get; set; }
|
||||||
|
public string? ProjectName { get; set; }
|
||||||
public string? Tags { get; set; }
|
public string? Tags { get; set; }
|
||||||
|
|
||||||
// Conversion tracking
|
// Conversion tracking
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ public class QuoteItem : BaseEntity
|
|||||||
public int? AiPredictionId { get; set; }
|
public int? AiPredictionId { get; set; }
|
||||||
public virtual AiItemPrediction? AiPrediction { get; set; }
|
public virtual AiItemPrediction? AiPrediction { get; set; }
|
||||||
|
|
||||||
|
// Custom formula item — see IsCustomFormulaItem routing in PricingCalculationService
|
||||||
|
public bool IsCustomFormulaItem { get; set; }
|
||||||
|
public int? CustomItemTemplateId { get; set; }
|
||||||
|
public virtual CustomItemTemplate? CustomItemTemplate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Snapshot of field name/value pairs used in the formula, stored as JSON for display on details views.</summary>
|
||||||
|
public string? FormulaFieldValuesJson { get; set; }
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
public virtual Quote Quote { get; set; } = null!;
|
public virtual Quote Quote { get; set; } = null!;
|
||||||
public virtual CatalogItem? CatalogItem { get; set; }
|
public virtual CatalogItem? CatalogItem { get; set; }
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ public class QuoteItemCoat : BaseEntity
|
|||||||
|
|
||||||
// Powder selection (same pattern as current QuoteItem)
|
// Powder selection (same pattern as current QuoteItem)
|
||||||
public int? InventoryItemId { get; set; } // In-stock powder
|
public int? InventoryItemId { get; set; } // In-stock powder
|
||||||
|
/// <summary>
|
||||||
|
/// Platform powder catalog item that this coat was sourced from.
|
||||||
|
/// Persisted so that at quote-approval time the system can create exactly one
|
||||||
|
/// IsIncoming InventoryItem per unique catalog powder (deduplication), rather
|
||||||
|
/// than creating during quote-save when the job may never be approved.
|
||||||
|
/// </summary>
|
||||||
|
public int? PowderCatalogItemId { get; set; }
|
||||||
public string? ColorName { get; set; } // Color name
|
public string? ColorName { get; set; } // Color name
|
||||||
public int? VendorId { get; set; } // Vendor for custom powder
|
public int? VendorId { get; set; } // Vendor for custom powder
|
||||||
public string? ColorCode { get; set; } // RAL code, etc.
|
public string? ColorCode { get; set; } // RAL code, etc.
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ public class ReworkRecord : BaseEntity
|
|||||||
public bool IsBillableToCustomer { get; set; }
|
public bool IsBillableToCustomer { get; set; }
|
||||||
public string? BillingNotes { get; set; }
|
public string? BillingNotes { get; set; }
|
||||||
|
|
||||||
|
// Pricing attribution for the linked rework job (null on pre-existing records)
|
||||||
|
public ReworkPricingType? ReworkPricingType { get; set; }
|
||||||
|
|
||||||
// ── Resolution ────────────────────────────────────────────────────────────
|
// ── Resolution ────────────────────────────────────────────────────────────
|
||||||
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
public ReworkStatus Status { get; set; } = ReworkStatus.Open;
|
||||||
public ReworkResolution? Resolution { get; set; }
|
public ReworkResolution? Resolution { get; set; }
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ public class SubscriptionPlanConfig : BaseEntity
|
|||||||
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
/// <summary>When true, companies on this plan can send SMS notifications to customers (subject to platform kill-switch and per-company opt-in).</summary>
|
||||||
public bool AllowSms { get; set; } = false;
|
public bool AllowSms { get; set; } = false;
|
||||||
|
|
||||||
|
/// <summary>When true, companies on this plan can create and use Custom Formula Item Templates in quotes and jobs.</summary>
|
||||||
|
public bool AllowCustomFormulas { get; set; } = false;
|
||||||
|
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ public class Vendor : BaseEntity
|
|||||||
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
public virtual ICollection<BillPayment> BillPayments { get; set; } = new List<BillPayment>();
|
||||||
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
public virtual ICollection<Expense> Expenses { get; set; } = new List<Expense>();
|
||||||
public virtual Account? DefaultExpenseAccount { get; set; }
|
public virtual Account? DefaultExpenseAccount { get; set; }
|
||||||
|
public virtual ICollection<InventoryCategoryLookup> Categories { get; set; } = new List<InventoryCategoryLookup>();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class InventoryTransaction : BaseEntity
|
public class InventoryTransaction : BaseEntity
|
||||||
@@ -151,6 +152,20 @@ public class CustomerNote : BaseEntity
|
|||||||
public virtual Customer Customer { get; set; } = null!;
|
public virtual Customer Customer { get; set; } = null!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records an inventory item as a preferred powder for a specific customer.
|
||||||
|
/// Shown on Customer Details for faster quoting of repeat orders.
|
||||||
|
/// </summary>
|
||||||
|
public class CustomerPreferredPowder : BaseEntity
|
||||||
|
{
|
||||||
|
public int CustomerId { get; set; }
|
||||||
|
public int InventoryItemId { get; set; }
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
|
||||||
|
public virtual Customer Customer { get; set; } = null!;
|
||||||
|
public virtual InventoryItem InventoryItem { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
public class JobStatusHistory : BaseEntity
|
public class JobStatusHistory : BaseEntity
|
||||||
{
|
{
|
||||||
public int JobId { get; set; }
|
public int JobId { get; set; }
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an activated shop-floor kiosk tablet for the timeclock.
|
||||||
|
/// One row per device; multiple rows per company are supported so shops can have
|
||||||
|
/// tablets at multiple entry points. The <see cref="Token"/> is stored in a
|
||||||
|
/// device-specific cookie and validated on every kiosk request.
|
||||||
|
/// </summary>
|
||||||
|
public class TimeclockKioskDevice : BaseEntity
|
||||||
|
{
|
||||||
|
/// <summary>Human-readable label for this device (e.g. "Front Entrance Tablet").</summary>
|
||||||
|
public string? DeviceName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Cryptographically random token written to the device cookie on activation. Revoke by deleting this row.</summary>
|
||||||
|
public string Token { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp when a manager activated this device.</summary>
|
||||||
|
public DateTime ActivatedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC timestamp of the most recent kiosk request from this device; null if never used after activation.</summary>
|
||||||
|
public DateTime? LastSeenAt { get; set; }
|
||||||
|
}
|
||||||
@@ -144,6 +144,14 @@ public enum ReworkResolution
|
|||||||
NoActionRequired = 4
|
NoActionRequired = 4
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Who bears the cost of the rework job, recorded at the time the rework is logged.</summary>
|
||||||
|
public enum ReworkPricingType
|
||||||
|
{
|
||||||
|
ShopFault = 0, // Redo is on the shop — rework job items priced at $0
|
||||||
|
CustomerReduced = 1, // Customer caused it; we're helping — prices copied, user edits
|
||||||
|
CustomerFull = 2 // Customer caused it; full original pricing applies
|
||||||
|
}
|
||||||
|
|
||||||
public enum BugReportStatus
|
public enum BugReportStatus
|
||||||
{
|
{
|
||||||
New = 0,
|
New = 0,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Labels what kind of time a <see cref="PowderCoating.Core.Entities.EmployeeClockEntry"/> represents.
|
||||||
|
/// Only <see cref="Work"/> segments count toward paid-hours totals; Break and Lunch are informational.
|
||||||
|
/// </summary>
|
||||||
|
public enum ClockEntryType
|
||||||
|
{
|
||||||
|
/// <summary>Normal productive work time (default).</summary>
|
||||||
|
Work = 0,
|
||||||
|
|
||||||
|
/// <summary>Short rest/break period — unpaid, excluded from hour totals.</summary>
|
||||||
|
Break = 1,
|
||||||
|
|
||||||
|
/// <summary>Meal/lunch period — unpaid, excluded from hour totals.</summary>
|
||||||
|
Lunch = 2
|
||||||
|
}
|
||||||
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
|
|||||||
IJobPhotoRepository JobPhotos { get; }
|
IJobPhotoRepository JobPhotos { get; }
|
||||||
IRepository<JobNote> JobNotes { get; }
|
IRepository<JobNote> JobNotes { get; }
|
||||||
IRepository<CustomerNote> CustomerNotes { get; }
|
IRepository<CustomerNote> CustomerNotes { get; }
|
||||||
|
IRepository<CustomerContact> CustomerContacts { get; }
|
||||||
|
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
|
||||||
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
IRepository<JobStatusHistory> JobStatusHistory { get; }
|
||||||
IRepository<PricingTier> PricingTiers { get; }
|
IRepository<PricingTier> PricingTiers { get; }
|
||||||
|
|
||||||
@@ -155,6 +157,18 @@ IRepository<ReworkRecord> ReworkRecords { get; }
|
|||||||
// Customer Intake Kiosk
|
// Customer Intake Kiosk
|
||||||
IRepository<KioskSession> KioskSessions { get; }
|
IRepository<KioskSession> KioskSessions { get; }
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
IRepository<CustomItemTemplate> CustomItemTemplates { get; }
|
||||||
|
|
||||||
|
// Formula Community Library
|
||||||
|
IPlainRepository<FormulaLibraryItem> FormulaLibrary { get; }
|
||||||
|
IRepository<FormulaLibraryImport> FormulaLibraryImports { get; }
|
||||||
|
IPlainRepository<FormulaLibraryRating> FormulaLibraryRatings { get; }
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
IRepository<EmployeeClockEntry> EmployeeClockEntries { get; }
|
||||||
|
IRepository<TimeclockKioskDevice> TimeclockKioskDevices { get; }
|
||||||
|
|
||||||
Task<int> SaveChangesAsync();
|
Task<int> SaveChangesAsync();
|
||||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||||
|
|
||||||
|
|||||||
@@ -92,4 +92,10 @@ public interface IJobRepository : IRepository<Job>
|
|||||||
/// were never completed and rolled past their scheduled day.
|
/// were never completed and rolled past their scheduled day.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
|
||||||
|
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
|
||||||
|
/// </summary>
|
||||||
|
Task<int> GetReworkJobCountAsync(int originalJobId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
public DbSet<JobNote> JobNotes { get; set; }
|
public DbSet<JobNote> JobNotes { get; set; }
|
||||||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||||||
|
/// <summary>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<CustomerContact> CustomerContacts { get; set; }
|
||||||
|
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
|
||||||
|
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
|
||||||
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||||||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||||||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||||||
@@ -289,6 +293,15 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Community library of shared formula templates. Platform-level, no tenant filter.</summary>
|
||||||
|
public DbSet<FormulaLibraryItem> FormulaLibraryItems { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-company record of which community library formulas a company has imported.</summary>
|
||||||
|
public DbSet<FormulaLibraryImport> FormulaLibraryImports { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-company thumbs-up / thumbs-down vote on community library formulas.</summary>
|
||||||
|
public DbSet<FormulaLibraryRating> FormulaLibraryRatings { get; set; }
|
||||||
|
|
||||||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<BugReport> BugReports { get; set; }
|
public DbSet<BugReport> BugReports { get; set; }
|
||||||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||||||
@@ -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>
|
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||||
public DbSet<KioskSession> KioskSessions { get; set; }
|
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
/// <summary>Per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
|
public DbSet<CustomItemTemplate> CustomItemTemplates { get; set; }
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
/// <summary>Facility-level clock-in/clock-out entries per employee; tenant-filtered with soft delete. Multiple entries per day are supported (lunch breaks, etc.).</summary>
|
||||||
|
public DbSet<EmployeeClockEntry> EmployeeClockEntries { get; set; }
|
||||||
|
|
||||||
|
/// <summary>One row per activated kiosk tablet per company. Token stored in device cookie; delete row to revoke a device.</summary>
|
||||||
|
public DbSet<TimeclockKioskDevice> TimeclockKioskDevices { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||||
/// No global query filter — SuperAdmin controllers query this directly.
|
/// No global query filter — SuperAdmin controllers query this directly.
|
||||||
@@ -531,6 +555,8 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
|||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||||
@@ -767,6 +793,32 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(k => k.LinkedJobId)
|
.HasForeignKey(k => k.LinkedJobId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Custom Formula Templates — tenant-filtered + soft delete
|
||||||
|
modelBuilder.Entity<CustomItemTemplate>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
|
||||||
|
// Employee Timeclock — tenant-filtered + soft delete
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>().HasQueryFilter(e =>
|
||||||
|
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||||
|
// FK to ApplicationUser: Restrict delete so removing a user doesn't erase attendance history.
|
||||||
|
// Use DeleteBehavior.Restrict rather than NoAction to surface a cleaner error in EF.
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>()
|
||||||
|
.HasOne(c => c.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(c => c.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
// Composite index for "who's clocked in today" and date-range attendance reports
|
||||||
|
modelBuilder.Entity<EmployeeClockEntry>()
|
||||||
|
.HasIndex(c => new { c.CompanyId, c.ClockInTime });
|
||||||
|
|
||||||
|
// Timeclock kiosk devices — one row per activated tablet per company
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>().HasQueryFilter(d =>
|
||||||
|
!d.IsDeleted && (IsPlatformAdmin || d.CompanyId == CurrentCompanyId));
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||||
|
.HasIndex(d => d.Token).IsUnique();
|
||||||
|
modelBuilder.Entity<TimeclockKioskDevice>()
|
||||||
|
.HasIndex(d => d.CompanyId);
|
||||||
|
|
||||||
// Account self-referencing hierarchy
|
// Account self-referencing hierarchy
|
||||||
modelBuilder.Entity<Account>()
|
modelBuilder.Entity<Account>()
|
||||||
.HasOne(a => a.ParentAccount)
|
.HasOne(a => a.ParentAccount)
|
||||||
@@ -809,6 +861,15 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
// Vendor ↔ InventoryCategoryLookup (many-to-many supply categories)
|
||||||
|
modelBuilder.Entity<Vendor>()
|
||||||
|
.HasMany(v => v.Categories)
|
||||||
|
.WithMany(c => c.Vendors)
|
||||||
|
.UsingEntity<Dictionary<string, object>>(
|
||||||
|
"VendorInventoryCategories",
|
||||||
|
j => j.HasOne<InventoryCategoryLookup>().WithMany().HasForeignKey("InventoryCategoryLookupId"),
|
||||||
|
j => j.HasOne<Vendor>().WithMany().HasForeignKey("VendorId"));
|
||||||
|
|
||||||
// Bill → APAccount (no cascade to avoid cycles)
|
// Bill → APAccount (no cascade to avoid cycles)
|
||||||
modelBuilder.Entity<Bill>()
|
modelBuilder.Entity<Bill>()
|
||||||
.HasOne(b => b.APAccount)
|
.HasOne(b => b.APAccount)
|
||||||
@@ -1664,6 +1725,23 @@ modelBuilder.Entity<Job>()
|
|||||||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||||||
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||||
|
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||||
|
.HasOne(p => p.Customer)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.CustomerId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CustomerPreferredPowder>()
|
||||||
|
.HasOne(p => p.InventoryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.InventoryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
// END PERFORMANCE OPTIMIZATION INDEXES
|
// END PERFORMANCE OPTIMIZATION INDEXES
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -2028,6 +2106,61 @@ modelBuilder.Entity<Job>()
|
|||||||
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
||||||
|
|
||||||
|
// FormulaLibraryItem — platform-level, no tenant filter, no soft delete
|
||||||
|
// Self-referential "Inspired by" FK uses NoAction; cascade nullification handled in service.
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasOne(f => f.InspiredBy)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(f => f.InspiredByFormulaLibraryItemId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasIndex(f => f.SourceCompanyId)
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryItem>()
|
||||||
|
.HasIndex(f => f.IsPublished)
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||||
|
|
||||||
|
// FormulaLibraryImport — tenant-scoped; unique per (CompanyId, FormulaLibraryItemId)
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasOne(i => i.FormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.FormulaLibraryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasOne(i => i.ResultingCustomItemTemplate)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(i => i.ResultingCustomItemTemplateId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryImport>()
|
||||||
|
.HasIndex(i => new { i.CompanyId, i.FormulaLibraryItemId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||||
|
|
||||||
|
// FormulaLibraryRating — platform-level; one vote per company per formula
|
||||||
|
modelBuilder.Entity<FormulaLibraryRating>()
|
||||||
|
.HasOne(r => r.FormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(r => r.FormulaLibraryItemId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<FormulaLibraryRating>()
|
||||||
|
.HasIndex(r => new { r.FormulaLibraryItemId, r.CompanyId })
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||||
|
|
||||||
|
// CustomItemTemplate → FormulaLibraryItem (nullable; only set on imported templates)
|
||||||
|
modelBuilder.Entity<CustomItemTemplate>()
|
||||||
|
.HasOne(t => t.SourceFormulaLibraryItem)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(t => t.SourceFormulaLibraryItemId)
|
||||||
|
.IsRequired(false)
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Generated
+10642
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddReworkPricingType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "ReworkPricingType",
|
||||||
|
table: "ReworkRecords",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ReworkPricingType",
|
||||||
|
table: "ReworkRecords");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10672
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,93 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddVendorCategories : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "VendorInventoryCategories",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
InventoryCategoryLookupId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
VendorId = table.Column<int>(type: "int", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_VendorInventoryCategories", x => new { x.InventoryCategoryLookupId, x.VendorId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VendorInventoryCategories_InventoryCategoryLookups_InventoryCategoryLookupId",
|
||||||
|
column: x => x.InventoryCategoryLookupId,
|
||||||
|
principalTable: "InventoryCategoryLookups",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_VendorInventoryCategories_Vendors_VendorId",
|
||||||
|
column: x => x.VendorId,
|
||||||
|
principalTable: "Vendors",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_VendorInventoryCategories_VendorId",
|
||||||
|
table: "VendorInventoryCategories",
|
||||||
|
column: "VendorId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "VendorInventoryCategories");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8533));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8542));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 21, 14, 32, 24, 337, DateTimeKind.Utc).AddTicks(8543));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10780
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCustomItemTemplates : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "QuoteItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "QuoteItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "QuoteItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "JobItems",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "CustomItemTemplates",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
OutputMode = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
FieldsJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
Formula = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||||
|
DefaultRate = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
RateLabel = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
DisplayOrder = table.Column<int>(type: "int", nullable: false),
|
||||||
|
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DiagramImagePath = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_CustomItemTemplates", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9869));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9876));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 15, 30, 51, 760, DateTimeKind.Utc).AddTicks(9878));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems",
|
||||||
|
column: "CustomItemTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_JobItems_CustomItemTemplateId",
|
||||||
|
table: "JobItems",
|
||||||
|
column: "CustomItemTemplateId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "JobItems",
|
||||||
|
column: "CustomItemTemplateId",
|
||||||
|
principalTable: "CustomItemTemplates",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems",
|
||||||
|
column: "CustomItemTemplateId",
|
||||||
|
principalTable: "CustomItemTemplates",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_JobItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_QuoteItems_CustomItemTemplates_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "CustomItemTemplates");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_QuoteItems_CustomItemTemplateId",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_JobItems_CustomItemTemplateId",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "QuoteItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CustomItemTemplateId",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FormulaFieldValuesJson",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsCustomFormulaItem",
|
||||||
|
table: "JobItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10672
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class MakeMaintenanceIntervalNullable : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
type: "int",
|
||||||
|
nullable: true,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterColumn<int>(
|
||||||
|
name: "RecommendedMaintenanceIntervalDays",
|
||||||
|
table: "Equipment",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0,
|
||||||
|
oldClrType: typeof(int),
|
||||||
|
oldType: "int",
|
||||||
|
oldNullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4300));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4313));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 23, 13, 51, 6, 293, DateTimeKind.Utc).AddTicks(4315));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10783
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddAllowCustomFormulas : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "AllowCustomFormulas",
|
||||||
|
table: "SubscriptionPlanConfigs",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AllowCustomFormulas",
|
||||||
|
table: "SubscriptionPlanConfigs");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8197));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8203));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 24, 14, 36, 3, 317, DateTimeKind.Utc).AddTicks(8204));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10854
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,115 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddEmployeeTimeclock : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "KioskPin",
|
||||||
|
table: "AspNetUsers",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "EmployeeClockEntries",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
UserId = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
ClockInTime = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
ClockOutTime = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
HoursWorked = table.Column<decimal>(type: "decimal(18,2)", nullable: true),
|
||||||
|
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_EmployeeClockEntries", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_EmployeeClockEntries_AspNetUsers_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "AspNetUsers",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Restrict);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmployeeClockEntries_CompanyId_ClockInTime",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
columns: new[] { "CompanyId", "ClockInTime" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_EmployeeClockEntries_UserId",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
column: "UserId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "EmployeeClockEntries");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "KioskPin",
|
||||||
|
table: "AspNetUsers");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8290));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8297));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 25, 16, 55, 17, 422, DateTimeKind.Utc).AddTicks(8298));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10857
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTimeclockKioskToken : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1387));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1395));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 0, 58, 898, DateTimeKind.Utc).AddTicks(1397));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10918
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTimeclockSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||||
|
table: "Companies",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "TimeclockAutoClockOutHours",
|
||||||
|
table: "Companies",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "TimeclockEnabled",
|
||||||
|
table: "Companies",
|
||||||
|
type: "bit",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TimeclockKioskDevices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<int>(type: "int", nullable: false)
|
||||||
|
.Annotation("SqlServer:Identity", "1, 1"),
|
||||||
|
DeviceName = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
Token = table.Column<string>(type: "nvarchar(450)", nullable: false),
|
||||||
|
ActivatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
LastSeenAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||||
|
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||||
|
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||||
|
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||||
|
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||||
|
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TimeclockKioskDevices", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TimeclockKioskDevices_CompanyId",
|
||||||
|
table: "TimeclockKioskDevices",
|
||||||
|
column: "CompanyId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TimeclockKioskDevices_Token",
|
||||||
|
table: "TimeclockKioskDevices",
|
||||||
|
column: "Token",
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TimeclockKioskDevices");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockAllowMultiplePunchesPerDay",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockAutoClockOutHours",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "TimeclockEnabled",
|
||||||
|
table: "Companies");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "TimeclockKioskToken",
|
||||||
|
table: "Companies",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4791));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4801));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 26, 22, 10, 37, 196, DateTimeKind.Utc).AddTicks(4803));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+10921
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddClockEntryType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "EntryType",
|
||||||
|
table: "EmployeeClockEntries",
|
||||||
|
type: "int",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "EntryType",
|
||||||
|
table: "EmployeeClockEntries");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3772));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3777));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 4, 18, 51, 784, DateTimeKind.Utc).AddTicks(3779));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+10924
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddPowderCatalogItemIdToCoat : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "QuoteItemCoats",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8956));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8962));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 13, 46, 47, 552, DateTimeKind.Utc).AddTicks(8964));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "QuoteItemCoats");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3040));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3052));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 5, 27, 12, 41, 26, 605, DateTimeKind.Utc).AddTicks(3054));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11119
File diff suppressed because it is too large
Load Diff
@@ -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")
|
b.Property<bool>("IsBanned")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("KioskPin")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<decimal?>("LaborCostPerHour")
|
b.Property<decimal?>("LaborCostPerHour")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -1923,6 +1926,15 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("TimeZone")
|
b.Property<string>("TimeZone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("TimeclockAllowMultiplePunchesPerDay")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int?>("TimeclockAutoClockOutHours")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("TimeclockEnabled")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<DateTime?>("UpdatedAt")
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -2650,6 +2662,88 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("CreditMemoApplications");
|
b.ToTable("CreditMemoApplications");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<decimal?>("DefaultRate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DiagramImagePath")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("DisplayOrder")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("FieldsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Formula")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<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 =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2724,6 +2818,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime?>("LastContactDate")
|
b.Property<DateTime?>("LastContactDate")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("LeadSource")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("MobilePhone")
|
b.Property<string>("MobilePhone")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -2742,6 +2839,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("PricingTierId")
|
b.Property<int?>("PricingTierId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToAddress")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToCity")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToCountry")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToState")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ShipToZipCode")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("SmsConsentMethod")
|
b.Property<string>("SmsConsentMethod")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -2800,6 +2912,81 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("Customers");
|
b.ToTable("Customers");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("ContactRole")
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("nvarchar(50)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasMaxLength(200)
|
||||||
|
.HasColumnType("nvarchar(200)");
|
||||||
|
|
||||||
|
b.Property<string>("FirstName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("LastName")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<string>("MobilePhone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasMaxLength(500)
|
||||||
|
.HasColumnType("nvarchar(500)");
|
||||||
|
|
||||||
|
b.Property<string>("Phone")
|
||||||
|
.HasMaxLength(20)
|
||||||
|
.HasColumnType("nvarchar(20)");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("nvarchar(100)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.ToTable("CustomerContacts");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2850,6 +3037,58 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("CustomerNotes");
|
b.ToTable("CustomerNotes");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("CustomerId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("InventoryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InventoryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId", "InventoryItemId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
|
||||||
|
|
||||||
|
b.ToTable("CustomerPreferredPowders");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -2960,6 +3199,66 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("Deposits");
|
b.ToTable("Deposits");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ClockInTime")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ClockOutTime")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("EntryType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<decimal?>("HoursWorked")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("UserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "ClockInTime");
|
||||||
|
|
||||||
|
b.ToTable("EmployeeClockEntries");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3045,7 +3344,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("PurchasePrice")
|
b.Property<decimal>("PurchasePrice")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<int>("RecommendedMaintenanceIntervalDays")
|
b.Property<int?>("RecommendedMaintenanceIntervalDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("SerialNumber")
|
b.Property<string>("SerialNumber")
|
||||||
@@ -3291,6 +3590,183 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("FixedAssetDepreciationEntries");
|
b.ToTable("FixedAssetDepreciationEntries");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("FormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("ImportedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("ImportedByUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<int>("ResultingCustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FormulaLibraryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("ResultingCustomItemTemplateId");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId", "FormulaLibraryItemId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryImports_Company_Item");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryImports");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal?>("DefaultRate")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DiagramImagePath")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("FieldsJson")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Formula")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("ImportCount")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("IndustryHint")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("InspiredByFormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("OutputMode")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("RateLabel")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime>("SharedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("SharedByUserId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SourceCompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("SourceCompanyName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int>("SourceCustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("Tags")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("InspiredByFormulaLibraryItemId");
|
||||||
|
|
||||||
|
b.HasIndex("IsPublished")
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_IsPublished");
|
||||||
|
|
||||||
|
b.HasIndex("SourceCompanyId")
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryItems_SourceCompanyId");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryItems");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("FormulaLibraryItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPositive")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime>("RatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("FormulaLibraryItemId", "CompanyId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("IX_FormulaLibraryRatings_Item_Company");
|
||||||
|
|
||||||
|
b.ToTable("FormulaLibraryRatings");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -3938,6 +4414,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PreparedById")
|
b.Property<string>("PreparedById")
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("PublicViewToken")
|
b.Property<string>("PublicViewToken")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -4229,6 +4708,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("PricingBreakdownJson")
|
b.Property<string>("PricingBreakdownJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<int?>("QuoteId")
|
b.Property<int?>("QuoteId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4473,6 +4955,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("CustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -4489,12 +4974,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Finish")
|
b.Property<string>("Finish")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("FormulaFieldValuesJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsAiItem")
|
b.Property<bool>("IsAiItem")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCustomFormulaItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -4558,6 +5049,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CatalogItemId");
|
b.HasIndex("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasIndex("JobId")
|
b.HasIndex("JobId")
|
||||||
.HasDatabaseName("IX_JobItems_JobId");
|
.HasDatabaseName("IX_JobItems_JobId");
|
||||||
|
|
||||||
@@ -6711,7 +7204,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5186),
|
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6722,7 +7215,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5190),
|
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -6733,7 +7226,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 5, 19, 19, 26, 9, 226, DateTimeKind.Utc).AddTicks(5191),
|
CreatedAt = new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7043,6 +7536,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("ProfitPercent")
|
b.Property<decimal>("ProfitPercent")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<string>("ProjectName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ProspectAddress")
|
b.Property<string>("ProspectAddress")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -7260,6 +7756,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("CustomItemTemplateId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<DateTime?>("DeletedAt")
|
b.Property<DateTime?>("DeletedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
@@ -7273,12 +7772,18 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("EstimatedMinutes")
|
b.Property<int>("EstimatedMinutes")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<string>("FormulaFieldValuesJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<bool>("IncludePrepCost")
|
b.Property<bool>("IncludePrepCost")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsAiItem")
|
b.Property<bool>("IsAiItem")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("IsCustomFormulaItem")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("IsDeleted")
|
b.Property<bool>("IsDeleted")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -7348,6 +7853,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CatalogItemId");
|
b.HasIndex("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasIndex("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasIndex("QuoteId")
|
b.HasIndex("QuoteId")
|
||||||
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
||||||
|
|
||||||
@@ -7414,6 +7921,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("PowderCatalogItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<decimal?>("PowderCostPerLb")
|
b.Property<decimal?>("PowderCostPerLb")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -7990,6 +8500,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int?>("ReworkJobId")
|
b.Property<int?>("ReworkJobId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("ReworkPricingType")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int>("ReworkType")
|
b.Property<int>("ReworkType")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -8073,6 +8586,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<bool>("AllowAiPhotoQuotes")
|
b.Property<bool>("AllowAiPhotoQuotes")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<bool>("AllowCustomFormulas")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
b.Property<bool>("AllowOnlinePayments")
|
b.Property<bool>("AllowOnlinePayments")
|
||||||
.HasColumnType("bit");
|
.HasColumnType("bit");
|
||||||
|
|
||||||
@@ -8249,6 +8765,61 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("TermsAcceptances");
|
b.ToTable("TermsAcceptances");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.TimeclockKioskDevice", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
|
||||||
|
|
||||||
|
b.Property<DateTime>("ActivatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<int>("CompanyId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("DeletedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("DeletedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("DeviceName")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("bit");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeenAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("Token")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("UpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("UpdatedBy")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CompanyId");
|
||||||
|
|
||||||
|
b.HasIndex("Token")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("TimeclockKioskDevices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.UserPasskey", b =>
|
||||||
{
|
{
|
||||||
b.Property<int>("Id")
|
b.Property<int>("Id")
|
||||||
@@ -8631,6 +9202,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.ToTable("YearEndCloses");
|
b.ToTable("YearEndCloses");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("InventoryCategoryLookupId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int>("VendorId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.HasKey("InventoryCategoryLookupId", "VendorId");
|
||||||
|
|
||||||
|
b.HasIndex("VendorId");
|
||||||
|
|
||||||
|
b.ToTable("VendorInventoryCategories");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||||
@@ -9016,6 +9602,16 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Invoice");
|
b.Navigation("Invoice");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomItemTemplate", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "SourceFormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SourceFormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("SourceFormulaLibraryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -9032,6 +9628,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("PricingTier");
|
b.Navigation("PricingTier");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||||
|
.WithMany("CustomerContacts")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||||
@@ -9043,6 +9650,25 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("Customer");
|
b.Navigation("Customer");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InventoryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("InventoryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
|
||||||
@@ -9079,6 +9705,17 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("RecordedBy");
|
b.Navigation("RecordedBy");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.EmployeeClockEntry", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Equipment", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
b.HasOne("PowderCoating.Core.Entities.Company", null)
|
||||||
@@ -9161,6 +9798,46 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryImport", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "ResultingCustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("ResultingCustomItemTemplateId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("FormulaLibraryItem");
|
||||||
|
|
||||||
|
b.Navigation("ResultingCustomItemTemplate");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryItem", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "InspiredBy")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InspiredByFormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.Navigation("InspiredBy");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("PowderCoating.Core.Entities.FormulaLibraryRating", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.FormulaLibraryItem", "FormulaLibraryItem")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("FormulaLibraryItemId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("FormulaLibraryItem");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.GiftCertificate", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
b.HasOne("PowderCoating.Core.Entities.ApplicationUser", "IssuedBy")
|
||||||
@@ -9494,6 +10171,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.HasForeignKey("CatalogItemId")
|
.HasForeignKey("CatalogItemId")
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
|
b.HasOne("PowderCoating.Core.Entities.Job", "Job")
|
||||||
.WithMany("JobItems")
|
.WithMany("JobItems")
|
||||||
.HasForeignKey("JobId")
|
.HasForeignKey("JobId")
|
||||||
@@ -9504,6 +10185,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("CatalogItem");
|
b.Navigation("CatalogItem");
|
||||||
|
|
||||||
|
b.Navigation("CustomItemTemplate");
|
||||||
|
|
||||||
b.Navigation("Job");
|
b.Navigation("Job");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -10113,6 +10796,10 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("CatalogItemId");
|
.HasForeignKey("CatalogItemId");
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.CustomItemTemplate", "CustomItemTemplate")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomItemTemplateId");
|
||||||
|
|
||||||
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
|
b.HasOne("PowderCoating.Core.Entities.Quote", "Quote")
|
||||||
.WithMany("QuoteItems")
|
.WithMany("QuoteItems")
|
||||||
.HasForeignKey("QuoteId")
|
.HasForeignKey("QuoteId")
|
||||||
@@ -10123,6 +10810,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.Navigation("CatalogItem");
|
b.Navigation("CatalogItem");
|
||||||
|
|
||||||
|
b.Navigation("CustomItemTemplate");
|
||||||
|
|
||||||
b.Navigation("Quote");
|
b.Navigation("Quote");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -10369,6 +11058,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Navigation("JournalEntry");
|
b.Navigation("JournalEntry");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("VendorInventoryCategories", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.InventoryCategoryLookup", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InventoryCategoryLookupId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Vendor", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("VendorId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Account", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("BillLineItems");
|
b.Navigation("BillLineItems");
|
||||||
@@ -10460,6 +11164,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("CustomerContacts");
|
||||||
|
|
||||||
b.Navigation("CustomerNotes");
|
b.Navigation("CustomerNotes");
|
||||||
|
|
||||||
b.Navigation("Invoices");
|
b.Navigation("Invoices");
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="NCalc2" Version="2.1.0" />
|
||||||
<PackageReference Include="SendGrid" Version="9.29.3" />
|
<PackageReference Include="SendGrid" Version="9.29.3" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
|
||||||
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
<PackageReference Include="Stripe.net" Version="50.4.1" />
|
||||||
|
|||||||
@@ -187,6 +187,14 @@ public class JobRepository : Repository<Job>, IJobRepository
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
||||||
|
{
|
||||||
|
return await _context.Jobs
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.CountAsync(j => j.OriginalJobId == originalJobId);
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
public async Task<List<Job>> GetOverdueScheduledJobsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
private IJobPhotoRepository? _jobPhotos;
|
private IJobPhotoRepository? _jobPhotos;
|
||||||
private IRepository<JobNote>? _jobNotes;
|
private IRepository<JobNote>? _jobNotes;
|
||||||
private IRepository<CustomerNote>? _customerNotes;
|
private IRepository<CustomerNote>? _customerNotes;
|
||||||
|
private IRepository<CustomerContact>? _customerContacts;
|
||||||
|
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
|
||||||
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
private IRepository<JobStatusHistory>? _jobStatusHistory;
|
||||||
private IRepository<PricingTier>? _pricingTiers;
|
private IRepository<PricingTier>? _pricingTiers;
|
||||||
|
|
||||||
@@ -123,6 +125,18 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
// Customer Intake Kiosk
|
// Customer Intake Kiosk
|
||||||
private IRepository<KioskSession>? _kioskSessions;
|
private IRepository<KioskSession>? _kioskSessions;
|
||||||
|
|
||||||
|
// Employee Timeclock
|
||||||
|
private IRepository<EmployeeClockEntry>? _employeeClockEntries;
|
||||||
|
private IRepository<TimeclockKioskDevice>? _timeclockKioskDevices;
|
||||||
|
|
||||||
|
// Custom Formula Templates
|
||||||
|
private IRepository<CustomItemTemplate>? _customItemTemplates;
|
||||||
|
|
||||||
|
// Formula Community Library
|
||||||
|
private IPlainRepository<FormulaLibraryItem>? _formulaLibrary;
|
||||||
|
private IRepository<FormulaLibraryImport>? _formulaLibraryImports;
|
||||||
|
private IPlainRepository<FormulaLibraryRating>? _formulaLibraryRatings;
|
||||||
|
|
||||||
// Purchase Orders
|
// Purchase Orders
|
||||||
private IPurchaseOrderRepository? _purchaseOrders;
|
private IPurchaseOrderRepository? _purchaseOrders;
|
||||||
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
private IRepository<PurchaseOrderItem>? _purchaseOrderItems;
|
||||||
@@ -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>
|
/// <summary>Repository for <see cref="CustomerNote"/> free-text staff notes on customer records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<CustomerNote> CustomerNotes =>
|
public IRepository<CustomerNote> CustomerNotes =>
|
||||||
_customerNotes ??= new Repository<CustomerNote>(_context);
|
_customerNotes ??= new Repository<CustomerNote>(_context);
|
||||||
|
/// <summary>Repository for <see cref="CustomerContact"/> additional contacts (billing, ops, drop-off) on commercial accounts; tenant-filtered with soft delete.</summary>
|
||||||
|
public IRepository<CustomerContact> CustomerContacts =>
|
||||||
|
_customerContacts ??= new Repository<CustomerContact>(_context);
|
||||||
|
public IRepository<CustomerPreferredPowder> CustomerPreferredPowders =>
|
||||||
|
_customerPreferredPowders ??= new Repository<CustomerPreferredPowder>(_context);
|
||||||
|
|
||||||
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobStatusHistory"/> status-transition audit records; tenant-filtered with soft delete.</summary>
|
||||||
public IRepository<JobStatusHistory> JobStatusHistory =>
|
public IRepository<JobStatusHistory> JobStatusHistory =>
|
||||||
@@ -457,6 +476,30 @@ public class UnitOfWork : IUnitOfWork
|
|||||||
public IRepository<KioskSession> KioskSessions =>
|
public IRepository<KioskSession> KioskSessions =>
|
||||||
_kioskSessions ??= new Repository<KioskSession>(_context);
|
_kioskSessions ??= new Repository<KioskSession>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="EmployeeClockEntry"/> facility-level clock-in/clock-out records; tenant-filtered with soft delete. Multiple entries per day are fully supported.</summary>
|
||||||
|
public IRepository<EmployeeClockEntry> EmployeeClockEntries =>
|
||||||
|
_employeeClockEntries ??= new Repository<EmployeeClockEntry>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="TimeclockKioskDevice"/> activated tablet records; one row per device. Delete a row to revoke that device's access.</summary>
|
||||||
|
public IRepository<TimeclockKioskDevice> TimeclockKioskDevices =>
|
||||||
|
_timeclockKioskDevices ??= new Repository<TimeclockKioskDevice>(_context);
|
||||||
|
|
||||||
|
/// <summary>Repository for <see cref="CustomItemTemplate"/> per-company reusable NCalc pricing formula templates; tenant-filtered with soft delete.</summary>
|
||||||
|
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
|
// Job Templates
|
||||||
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
/// <summary>Repository for <see cref="JobTemplate"/> reusable job blueprints; tenant-filtered with soft delete.</summary>
|
||||||
public IJobTemplateRepository JobTemplates =>
|
public IJobTemplateRepository JobTemplates =>
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
JobNumber = "JOB-2601-0001",
|
JobNumber = "JOB-2601-0001",
|
||||||
CustomerEmail = "customer@example.com",
|
CustomerEmail = "customer@example.com",
|
||||||
CustomerName = "Acme Corp (used if email is blank or not found)",
|
CustomerName = "Acme Corp (used if email is blank or not found)",
|
||||||
|
Description = "Sample job description",
|
||||||
Status = "Pending",
|
Status = "Pending",
|
||||||
Priority = "Normal",
|
Priority = "Normal",
|
||||||
ScheduledDate = DateTime.Today.AddDays(7),
|
ScheduledDate = DateTime.Today.AddDays(7),
|
||||||
@@ -269,7 +270,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
FinalPrice = 750.00m,
|
FinalPrice = 750.00m,
|
||||||
CustomerPO = "PO-12345",
|
CustomerPO = "PO-12345",
|
||||||
SpecialInstructions = "Handle with care",
|
SpecialInstructions = "Handle with care",
|
||||||
Notes = "Sample job"
|
Notes = "Internal notes"
|
||||||
});
|
});
|
||||||
csv.NextRecord();
|
csv.NextRecord();
|
||||||
|
|
||||||
@@ -388,8 +389,15 @@ public class CsvImportService : ICsvImportService
|
|||||||
/// Imports customers from a CSV stream and persists valid rows to the database for the given company.
|
/// Imports customers from a CSV stream and persists valid rows to the database for the given company.
|
||||||
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
|
/// The import uses a two-phase approach: all rows are parsed and validated first, then each validated
|
||||||
/// entity is saved individually so that a single bad row does not roll back the entire batch.
|
/// entity is saved individually so that a single bad row does not roll back the entire batch.
|
||||||
/// Duplicate detection runs against both existing DB records (by email) and within the import file
|
/// Duplicate detection uses a three-tier strategy, each tier only engaged when the previous
|
||||||
/// itself, catching cases where the same email appears twice in one upload.
|
/// identifier is absent:
|
||||||
|
/// Tier 1 — email address (case-insensitive): if email is present and matches a DB record or
|
||||||
|
/// earlier batch row the row is skipped.
|
||||||
|
/// Tier 2 — name + normalised phone composite: used when email is absent. Combining name with
|
||||||
|
/// phone prevents false positives when two people share a number (e.g. a family).
|
||||||
|
/// Row is skipped on match.
|
||||||
|
/// Tier 3 — name + city/state/zip composite: used when both email and phone are absent.
|
||||||
|
/// Location data is imprecise so this emits a warning but still imports the row.
|
||||||
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
|
/// Pricing tiers are resolved by tier name; an unrecognised name is demoted to a warning and the
|
||||||
/// customer is imported without a tier rather than being skipped entirely.
|
/// customer is imported without a tier rather than being skipped entirely.
|
||||||
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a
|
/// Contact names are split on the first space into FirstName / LastName because the CSV carries a
|
||||||
@@ -418,15 +426,53 @@ public class CsvImportService : ICsvImportService
|
|||||||
|
|
||||||
// Get all existing customers for duplicate detection
|
// Get all existing customers for duplicate detection
|
||||||
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
|
var existingCustomers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
|
|
||||||
|
// Tier 1 lookup: email → existing customer
|
||||||
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
|
var existingEmails = existingCustomers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||||
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(c => c.Email!.ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Tier 2 lookup: (normalised phone + "|" + display name) → existing customer.
|
||||||
|
// Combining name with phone avoids false positives when two people share a number.
|
||||||
|
var existingByPhoneAndName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in existingCustomers)
|
||||||
|
{
|
||||||
|
var phone = NormalizePhone(c.MobilePhone) ?? NormalizePhone(c.Phone);
|
||||||
|
if (phone == null) continue;
|
||||||
|
var name = string.IsNullOrWhiteSpace(c.CompanyName)
|
||||||
|
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||||||
|
: c.CompanyName;
|
||||||
|
var key = $"{phone}|{name}";
|
||||||
|
if (!existingByPhoneAndName.ContainsKey(key))
|
||||||
|
existingByPhoneAndName[key] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tier 3 lookup: (display name + "|" + city + "|" + state + "|" + zip) → existing customer.
|
||||||
|
// Only populated when a customer has at least one location field so the key isn't trivially blank.
|
||||||
|
var existingByNameAndLocation = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in existingCustomers)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(c.City) && string.IsNullOrWhiteSpace(c.State) && string.IsNullOrWhiteSpace(c.ZipCode))
|
||||||
|
continue;
|
||||||
|
var name = string.IsNullOrWhiteSpace(c.CompanyName)
|
||||||
|
? $"{c.ContactFirstName} {c.ContactLastName}".Trim()
|
||||||
|
: c.CompanyName;
|
||||||
|
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||||
|
var key = $"{name}|{c.City}|{c.State}|{c.ZipCode}";
|
||||||
|
if (!existingByNameAndLocation.ContainsKey(key))
|
||||||
|
existingByNameAndLocation[key] = c;
|
||||||
|
}
|
||||||
|
|
||||||
// Get pricing tiers for lookup
|
// Get pricing tiers for lookup
|
||||||
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync();
|
||||||
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
|
var pricingTierDict = pricingTiers.ToDictionary(pt => pt.TierName.ToUpper(), pt => pt, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
|
var customersToImport = new List<(int RowNumber, Customer Customer, string Email)>();
|
||||||
|
|
||||||
|
// Within-batch tracking sets (prevent duplicate detection against rows already queued)
|
||||||
|
var batchEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var batchPhoneAndName = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
var batchNameAndLocation = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
foreach (var record in records)
|
foreach (var record in records)
|
||||||
{
|
{
|
||||||
rowNumber++;
|
rowNumber++;
|
||||||
@@ -434,7 +480,12 @@ public class CsvImportService : ICsvImportService
|
|||||||
{
|
{
|
||||||
// Strip any literal quote characters that QB/Excel may wrap around field values
|
// Strip any literal quote characters that QB/Excel may wrap around field values
|
||||||
var cleanCompanyName = StripQuotes(record.CompanyName);
|
var cleanCompanyName = StripQuotes(record.CompanyName);
|
||||||
var cleanEmail = StripQuotes(record.Email);
|
// Normalise to null (not empty string) — the UNIQUE index on (CompanyId, Email)
|
||||||
|
// uses HasFilter("[Email] IS NOT NULL"), so NULL is allowed for multiple rows
|
||||||
|
// but "" (empty string) is not NULL and would cause a unique-constraint violation
|
||||||
|
// on the second blank-email customer saved.
|
||||||
|
var rawEmail = StripQuotes(record.Email);
|
||||||
|
var cleanEmail = string.IsNullOrWhiteSpace(rawEmail) ? null : rawEmail;
|
||||||
var firstName = StripQuotes(record.ContactFirstName)?.Trim();
|
var firstName = StripQuotes(record.ContactFirstName)?.Trim();
|
||||||
var lastName = StripQuotes(record.ContactLastName)?.Trim();
|
var lastName = StripQuotes(record.ContactLastName)?.Trim();
|
||||||
|
|
||||||
@@ -451,20 +502,68 @@ public class CsvImportService : ICsvImportService
|
|||||||
cleanCompanyName = derivedName;
|
cleanCompanyName = derivedName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for duplicate email in existing data
|
// Canonical display name used as part of composite keys in Tiers 2 and 3
|
||||||
if (!string.IsNullOrEmpty(cleanEmail) && existingEmails.ContainsKey(cleanEmail.ToLower()))
|
var displayName = string.IsNullOrWhiteSpace(cleanCompanyName)
|
||||||
{
|
? $"{firstName} {lastName}".Trim()
|
||||||
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
|
: cleanCompanyName;
|
||||||
result.SkippedCount++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for duplicate email within the import batch
|
// --- Tier 1: email dedup ---
|
||||||
if (!string.IsNullOrEmpty(cleanEmail) && customersToImport.Any(x => x.Email.Equals(cleanEmail, StringComparison.OrdinalIgnoreCase)))
|
// Only engaged when the row has an email address.
|
||||||
|
if (!string.IsNullOrEmpty(cleanEmail))
|
||||||
{
|
{
|
||||||
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
|
if (existingEmails.ContainsKey(cleanEmail.ToLower()))
|
||||||
result.SkippedCount++;
|
{
|
||||||
continue;
|
result.Warnings.Add($"Row {rowNumber}: Customer with email '{cleanEmail}' already exists in database. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (batchEmails.Contains(cleanEmail))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Duplicate email '{cleanEmail}' found in import file. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// --- Tier 2: name + phone composite dedup (email absent) ---
|
||||||
|
// Requiring both name and phone to match avoids false positives when two
|
||||||
|
// unrelated people happen to share a phone number (e.g. a shared office line).
|
||||||
|
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
|
||||||
|
if (normalizedPhone != null)
|
||||||
|
{
|
||||||
|
var phoneNameKey = $"{normalizedPhone}|{displayName}";
|
||||||
|
if (existingByPhoneAndName.TryGetValue(phoneNameKey, out var existingMatch))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; name + phone matches existing customer '{existingMatch.CompanyName}'. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (batchPhoneAndName.Contains(phoneNameKey))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email; duplicate name + phone found in import file. Skipping.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// --- Tier 3: name + location composite warning (no email, no phone) ---
|
||||||
|
// Location data is imprecise so we warn but still import — a name + city
|
||||||
|
// collision across unrelated people is plausible enough not to hard-skip.
|
||||||
|
var city = record.City?.Trim();
|
||||||
|
var state = record.State?.Trim();
|
||||||
|
var zip = record.ZipCode?.Trim();
|
||||||
|
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
|
||||||
|
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
|
||||||
|
{
|
||||||
|
var locationKey = $"{displayName}|{city}|{state}|{zip}";
|
||||||
|
if (existingByNameAndLocation.ContainsKey(locationKey) || batchNameAndLocation.Contains(locationKey))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: '{displayName}' has no email or phone; name + location matches an existing record. Imported anyway — verify manually.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve pricing tier
|
// Resolve pricing tier
|
||||||
@@ -506,12 +605,41 @@ public class CsvImportService : ICsvImportService
|
|||||||
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
|
PaymentTerms = record.PaymentTerms?.Trim() ?? "Net 30",
|
||||||
IsTaxExempt = record.TaxExempt ?? false,
|
IsTaxExempt = record.TaxExempt ?? false,
|
||||||
GeneralNotes = record.Notes?.Trim(),
|
GeneralNotes = record.Notes?.Trim(),
|
||||||
|
LeadSource = record.LeadSource?.Trim(),
|
||||||
|
ShipToAddress = record.ShipToAddress?.Trim(),
|
||||||
|
ShipToCity = record.ShipToCity?.Trim(),
|
||||||
|
ShipToState = record.ShipToState?.Trim(),
|
||||||
|
ShipToZipCode = record.ShipToZipCode?.Trim(),
|
||||||
|
ShipToCountry = record.ShipToCountry?.Trim(),
|
||||||
IsActive = record.IsActive ?? true,
|
IsActive = record.IsActive ?? true,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
|
|
||||||
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
|
customersToImport.Add((rowNumber, customer, cleanEmail ?? string.Empty));
|
||||||
|
|
||||||
|
// Register in batch tracking so later rows are checked against this one
|
||||||
|
if (!string.IsNullOrEmpty(cleanEmail))
|
||||||
|
{
|
||||||
|
batchEmails.Add(cleanEmail);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var normalizedPhone = NormalizePhone(record.MobilePhone) ?? NormalizePhone(record.Phone);
|
||||||
|
if (normalizedPhone != null)
|
||||||
|
{
|
||||||
|
batchPhoneAndName.Add($"{normalizedPhone}|{displayName}");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var city = record.City?.Trim();
|
||||||
|
var state = record.State?.Trim();
|
||||||
|
var zip = record.ZipCode?.Trim();
|
||||||
|
var hasLocation = !string.IsNullOrWhiteSpace(city) || !string.IsNullOrWhiteSpace(state) || !string.IsNullOrWhiteSpace(zip);
|
||||||
|
if (hasLocation && !string.IsNullOrWhiteSpace(displayName))
|
||||||
|
batchNameAndLocation.Add($"{displayName}|{city}|{state}|{zip}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -1162,6 +1290,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
Total = record.Total,
|
Total = record.Total,
|
||||||
Notes = record.Notes?.Trim(),
|
Notes = record.Notes?.Trim(),
|
||||||
Terms = record.TermsAndConditions?.Trim(),
|
Terms = record.TermsAndConditions?.Trim(),
|
||||||
|
ProjectName = record.ProjectName?.Trim(),
|
||||||
IsCommercial = customerId.HasValue,
|
IsCommercial = customerId.HasValue,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
@@ -1268,24 +1397,22 @@ public class CsvImportService : ICsvImportService
|
|||||||
MissingFieldFound = null
|
MissingFieldFound = null
|
||||||
});
|
});
|
||||||
|
|
||||||
var records = csv.GetRecords<JobImportDto>().ToList();
|
// Treat non-numeric values in decimal? fields (e.g. a spreadsheet "false" in FinalPrice)
|
||||||
result.TotalRows = records.Count;
|
// as null rather than throwing a fatal TypeConverterException.
|
||||||
|
csv.Context.TypeConverterCache.AddConverter<decimal?>(new LenientNullableDecimalConverter());
|
||||||
|
|
||||||
_logger.LogInformation("Starting import of {Count} jobs for company {CompanyId}", records.Count, companyId);
|
// Read header row first so we know field count before iterating rows.
|
||||||
|
await csv.ReadAsync();
|
||||||
|
csv.ReadHeader();
|
||||||
|
|
||||||
// Get all existing jobs for duplicate detection
|
// Pre-load lookup data before streaming rows so async calls don't interleave with CSV reads.
|
||||||
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
|
var existingJobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||||
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
var existingJobNumbers = existingJobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||||
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(j => j.JobNumber.ToUpper(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Get customers for lookup — build two dictionaries so we can resolve by email
|
|
||||||
// first and fall back to company name when the customer has no email on file.
|
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync();
|
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
var customerByEmail = customers.Where(c => !string.IsNullOrEmpty(c.Email))
|
||||||
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(c => c.Email!.Trim().ToLower(), c => c, StringComparer.OrdinalIgnoreCase);
|
||||||
// Name fallback: keyed on CompanyName (commercial) or "First Last" (non-commercial).
|
|
||||||
// TryAdd ensures that if two customers share the same name the first one wins and the
|
|
||||||
// lookup warning will prompt the user to resolve the ambiguity manually.
|
|
||||||
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
var customerByName = new Dictionary<string, Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
foreach (var c in customers)
|
foreach (var c in customers)
|
||||||
{
|
{
|
||||||
@@ -1296,19 +1423,42 @@ public class CsvImportService : ICsvImportService
|
|||||||
customerByName.TryAdd(name, c);
|
customerByName.TryAdd(name, c);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get job statuses for lookup
|
|
||||||
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync();
|
||||||
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
var jobStatusDict = jobStatuses.ToDictionary(js => js.StatusCode.ToUpper(), js => js, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Get job priorities for lookup
|
|
||||||
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
|
||||||
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
var jobPriorityDict = jobPriorities.ToDictionary(jp => jp.PriorityCode.ToUpper(), jp => jp, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
|
var jobsToImport = new List<(int RowNumber, Job Job, string JobNumber)>();
|
||||||
|
|
||||||
foreach (var record in records)
|
// Stream rows one at a time so a bad type conversion on a single row (e.g. "false"
|
||||||
|
// in a decimal field) is caught per-row rather than aborting the entire import.
|
||||||
|
while (await csv.ReadAsync())
|
||||||
{
|
{
|
||||||
rowNumber++;
|
result.TotalRows++;
|
||||||
|
JobImportDto record;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
record = csv.GetRecord<JobImportDto>()
|
||||||
|
?? throw new InvalidOperationException("Row returned null record.");
|
||||||
|
}
|
||||||
|
catch (Exception parseEx)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {csv.Context.Parser?.Row}: Could not parse row - {parseEx.InnerException?.Message ?? parseEx.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
_logger.LogWarning(parseEx, "Parse error at CSV row {Row}", csv.Context.Parser?.Row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
rowNumber = csv.Context.Parser?.Row ?? rowNumber + 1;
|
||||||
|
|
||||||
|
// Warn when FinalPrice was non-numeric (lenient converter returned null).
|
||||||
|
var rawFinalPrice = csv.TryGetField<string>(7, out var fpStr) ? fpStr : null;
|
||||||
|
if (!string.IsNullOrWhiteSpace(rawFinalPrice) && record.FinalPrice == null
|
||||||
|
&& !decimal.TryParse(rawFinalPrice, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out _))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: FinalPrice value '{rawFinalPrice}' could not be parsed as a number; defaulting to $0.");
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
@@ -1414,7 +1564,10 @@ public class CsvImportService : ICsvImportService
|
|||||||
CustomerPO = record.CustomerPO?.Trim(),
|
CustomerPO = record.CustomerPO?.Trim(),
|
||||||
SpecialInstructions = record.SpecialInstructions?.Trim(),
|
SpecialInstructions = record.SpecialInstructions?.Trim(),
|
||||||
InternalNotes = record.Notes?.Trim(),
|
InternalNotes = record.Notes?.Trim(),
|
||||||
Description = record.SpecialInstructions?.Trim() ?? "Imported job",
|
ProjectName = record.ProjectName?.Trim(),
|
||||||
|
Description = record.Description?.Trim()
|
||||||
|
?? record.SpecialInstructions?.Trim()
|
||||||
|
?? "Imported job",
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow
|
UpdatedAt = DateTime.UtcNow
|
||||||
};
|
};
|
||||||
@@ -2813,6 +2966,23 @@ public class CsvImportService : ICsvImportService
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalises a phone string to its last 10 digits for duplicate-detection comparisons.
|
||||||
|
/// Stripping to digits only means formatting differences such as (423) 331-9834,
|
||||||
|
/// 423-331-9834, and 4233319834 all produce the same key. Returns null when the input
|
||||||
|
/// contains fewer than 7 digits — too short to be a real phone number and avoids false
|
||||||
|
/// positive matches on placeholder values like "N/A" or extension-only strings.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="phone">Raw phone string as read from the CSV, or null.</param>
|
||||||
|
/// <returns>Last 10 (or all, if fewer than 10) digits of the input; null if input is unusable.</returns>
|
||||||
|
private static string? NormalizePhone(string? phone)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(phone)) return null;
|
||||||
|
var digits = new string(phone.Where(char.IsDigit).ToArray());
|
||||||
|
if (digits.Length < 7) return null;
|
||||||
|
return digits.Length >= 10 ? digits[^10..] : digits;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Invoice Import ───────────────────────────────────────────────────────────
|
// ── Invoice Import ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2984,9 +3154,10 @@ public class CsvImportService : ICsvImportService
|
|||||||
existing.DiscountAmount = record.DiscountAmount;
|
existing.DiscountAmount = record.DiscountAmount;
|
||||||
existing.Total = record.Total;
|
existing.Total = record.Total;
|
||||||
existing.AmountPaid = record.AmountPaid;
|
existing.AmountPaid = record.AmountPaid;
|
||||||
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
|
existing.CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim();
|
||||||
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
|
existing.Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim();
|
||||||
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
|
existing.Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim();
|
||||||
|
existing.ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim();
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
result.SuccessCount++;
|
result.SuccessCount++;
|
||||||
}
|
}
|
||||||
@@ -3008,9 +3179,10 @@ public class CsvImportService : ICsvImportService
|
|||||||
DiscountAmount = record.DiscountAmount,
|
DiscountAmount = record.DiscountAmount,
|
||||||
Total = record.Total,
|
Total = record.Total,
|
||||||
AmountPaid = record.AmountPaid,
|
AmountPaid = record.AmountPaid,
|
||||||
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
|
CustomerPO = string.IsNullOrWhiteSpace(record.CustomerPO) ? null : record.CustomerPO.Trim(),
|
||||||
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim(),
|
||||||
|
ProjectName = string.IsNullOrWhiteSpace(record.ProjectName) ? null : record.ProjectName.Trim()
|
||||||
};
|
};
|
||||||
await _unitOfWork.Invoices.AddAsync(invoice);
|
await _unitOfWork.Invoices.AddAsync(invoice);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -3340,4 +3512,23 @@ public class CsvImportService : ICsvImportService
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns null for any value that cannot be parsed as a decimal, instead of throwing a
|
||||||
|
/// TypeConverterException. Registered globally on the job CSV reader so that spreadsheet
|
||||||
|
/// artefacts like "false" in a price column are treated as $0 with a warning.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class LenientNullableDecimalConverter : CsvHelper.TypeConversion.ITypeConverter
|
||||||
|
{
|
||||||
|
public object? ConvertFromString(string? text, CsvHelper.IReaderRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
return decimal.TryParse(text, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var v)
|
||||||
|
? (object?)v
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? ConvertToString(object? value, CsvHelper.IWriterRow row, CsvHelper.Configuration.MemberMapData memberMapData)
|
||||||
|
=> value?.ToString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user