Compare commits

..

48 Commits

Author SHA1 Message Date
spouliot c4625ba28a Security: document unpatched System.Security.Cryptography.Xml advisory
GHSA-37gx-xxp4-5rgx and GHSA-w3x6-4m5h-cxqf (XML signature vulns) affect
8.0.2 transitively. No patched version exists in the NuGet feed yet — 9.0.0
is also flagged. Tracked in Directory.Build.props for re-check when a fix ships.

System.Net.Http 4.1.0 and System.Security.Cryptography.X509Certificates 4.1.0
are false positives: same NCalc2 -> Antlr4 -> NETStandard.Library 1.6.0 chain
already documented; .NET 8 BCL provides the runtime versions.

Microsoft.Build / NuGet.* are build-tooling-only, not deployed to production.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 22:17:46 -04:00
spouliot 9c1beab49e Security: add missing class-level [Authorize] on ReleaseNotesController and KioskController
ReleaseNotesController had [Authorize] only on Index(), leaving the class
unprotected at declaration level — any future unannotated action would be
publicly accessible.

KioskController had no class-level auth, meaning PushSmsConsent() and
CancelSmsConsent() (staff-only POST actions) were reachable by anonymous
callers. [AllowAnonymous] on the existing tablet/intake actions still
overrides correctly, so the customer-facing kiosk flow is unaffected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 21:44:59 -04:00
spouliot aeec899cf2 Performance: push ORDER BY/TAKE into SQL for hot-path reads
- IInAppNotificationRepository: typed repo with GetPagedAsync, GetRecentAsync, GetUnreadAsync
  — bell dropdown no longer loads all notifications then slices in C#
- Add compound indexes on InAppNotifications(CompanyId, IsDeleted, CreatedAt) and
  (CompanyId, IsDeleted, IsRead); ContactSubmissions(CompanyId, IsDeleted, CreatedAt)
- PlainRepository.GetAllAsync/FindAsync: add AsNoTracking (Announcements, Tips, ReleaseNotes)
- AiUsageReportController: replace GetAllAsync + C# Where with FindAsync (SQL-level filter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 21:34:12 -04:00
spouliot 54defc158f Multi-tenancy hardening: explicit companyId on all typed repository methods
All typed repository methods that previously relied solely on global query
filters now require an explicit companyId parameter, providing defense-in-
depth so IgnoreQueryFilters calls cannot leak cross-tenant data.

- IBillRepository/BillRepository: GetForIndexAsync, LoadForViewAsync,
  LoadForEditAsync, GetLastBillNumberAsync, GetLastPaymentNumberAsync,
  GetForDateRangeAsync all scoped to companyId
- IJobRepository/JobRepository: LoadForDetailsAsync, LoadForEditAsync,
  LoadForStatusChangeAsync, GetChangeHistoryAsync,
  LoadForTemplateSnapshotAsync, GetReworkJobCountAsync
- IQuoteRepository/QuoteRepository: LoadForDetailsAsync,
  GetChangeHistoryAsync, GetItemsWithCoatsAsync
- IInvoiceRepository/InvoiceRepository: LoadForViewAsync
- ICustomerRepository/CustomerRepository: LoadForDetailsAsync
- INotificationLogRepository/NotificationLogRepository: all 6 FK methods
- BillsController: ITenantContext injected, all call sites updated
- AccountingExportController, InvoicesController, JobsController,
  JobTemplatesController, QuotesController: call sites updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 19:12:23 -04:00
spouliot 8f11e00a0a Merge duplicate powder lines on dashboard order queue
When multiple jobs need the same powder, the 'Powder in Queue to be
Ordered' panel now collapses them into a single line (summed lbs) rather
than showing one row per coat. 'Mark as Ordered' marks all contributing
coats at once and injects each into the 'Awaiting Receipt' panel
individually so per-coat receiving still works unchanged.

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 12:59:10 -04:00
spouliot a21c05f655 Expand demo seed: 178 inventory items + 30 vendors
Inventory (11 → 178):
- 101 total powders: 6 core + 55 Prismatic + 20 Columbia + 13 Tiger Drylac + 9 Sherwin-Williams
- 77 supplies: 21 masking, 16 chemicals, 16 abrasives, 15 hanging hardware, 9 PPE
- ForceRemoveAll path now deletes all inventory for the company (not just
  the 11 enumerated SKUs), since transactions are pre-swept before this block

Vendors (5 → 30):
- Tiger Drylac, Sherwin-Williams Powders, Eastwood (powder suppliers)
- Clemco, Triangle Abrasives, Airgas, Linde (blasting/gases)
- Duke Energy, AT&T, Spectrum, Raleigh Electric, Carolina Industrial Water (utilities)
- Safety-Kleen, Raleigh Waste (environmental)
- Work N Gear, HD Supply, Carolina Office, First Insurance (services)
- Triangle Commercial Properties LLC (landlord — shop lease with address + terms)
- Fastenal, MSC, McMaster-Carr, Uline, Amazon Business, Lowe's Pro, NAPA (supply chain)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 10:06:41 -04:00
spouliot 1e5510477a Allow fractional quantities (< 1) in item wizard
Catalog, calculated, generic, formula, and AI item types now accept
decimal quantities (e.g. 0.25 for a quarter of a catalog set). Sales/
merchandise items remain whole-number only.

- Input min changed from 1 to 0.01; step="0.01" added where missing
- All parseInt reads on quantity inputs changed to parseFloat so values
  like 0.25 aren't truncated to 0 before being stored in wz.data
- Server-side Quantity is already decimal on all relevant DTOs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:41:06 -04:00
spouliot 6eb7be0193 Demo reset + dev banner suppression for DEMO company
- DemoController: company-code-gated reset action (DEMO only, CSRF protected)
- SeedDataService.Remove: FK-safe topological pre-sweep, all deletes scoped to companyId
- SeedDataService: clock entries, extra seed data, updated customer/worker/job-status seeders
- CompanySettingsController + Index.cshtml: Reset Demo Data button for DEMO company users
- ReportsController + FinancialReportService: supporting report fixes
- _Layout.cshtml: suppress env banner when current company is DEMO (all auth paths)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 09:26:40 -04:00
spouliot 7735fe3cce Demo data realism + invoice resend via SMS on any status
Seed data fixes:
- Fix EF interceptor: no longer overwrites explicitly-set CreatedAt on Added
  entities — root cause of all "same month" chart issues
- Customer seeder: generates 15 customers/month from Jan → current month;
  keeps 10 commercial anchors in deterministic order for job seeder index map
- Invoice seeder: historical range bumped from 2→8 paid invoices/month so
  P&L shows consistent profit (~$5,200 collected vs ~$4,200 monthly expenses)
- Month -1 bumped to 7 paid invoices to stay above expenses
- Jobs: set UpdatedAt to historical event date so analytics don't need null fallback
- Analytics (ReportsController): use CompletedDate ?? UpdatedAt ?? CreatedAt for
  revenue chart grouping; fixes empty Revenue Trend charts on Overview/Revenue tabs
- SeedDataService: inject IAccountBalanceService; auto-recalculate account balances
  after seeding; patch checking/savings opening balances unconditionally on reset
- Customer list: sort by CompanyName ?? ContactLastName so individuals and
  commercial accounts interleave instead of appearing as two blocks

Invoice resend:
- ResendInvoice action now accepts sendEmail + sendSms parameters; SMS-only
  resend no longer requires an email address on file
- Ensures PublicViewToken exists before SMS so the view link is always valid
- canResend in Details view now allows Paid invoices (removed != Paid guard)
- Resend button shows channel-choice modal when customer has both email + SMS,
  direct SMS button when SMS only, or email button when email only
- New #resendChannelModal mirrors the Send channel modal but posts to ResendInvoice
- resendInvoice() JS updated to pass sendEmail/sendSms query params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:20:04 -04:00
spouliot 249128e852 Fix Reset Demo Company: full wipe mode + missing removal categories
Root cause: fingerprint-based removal failed on databases seeded with
older code (different emails/SKUs); plus Vendors, Named Ovens, and
Appointments had no removal path at all.

- Add ForceRemoveAll flag to RemoveSeedDataOptions: when true, all
  removal blocks delete by CompanyId instead of fingerprint matching
- Customers block: ForceRemoveAll deletes all company customers
- Workers block: ForceRemoveAll deletes all users with CompanyRole=Worker
- New Vendors block (triggered by options.Vendors || ForceRemoveAll)
- New NamedOvens (OvenCost) block (triggered by options.NamedOvens || ForceRemoveAll)
- New Appointments block (triggered by options.Appointments || ForceRemoveAll)
- ResetDemoCompany: set ForceRemoveAll=true and enable all new flags so
  every re-seedable table is wiped clean before re-seeding

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:49:30 -04:00
spouliot c0e4a66126 Phase 4: Past appointments + AI prediction demo data
- Appointments: add ~25 past appointments (last 90 days) with Completed,
  Cancelled, No Show, and Rescheduled statuses; completed records carry
  ActualStartTime/ActualEndTime with realistic variance; cancel/no-show
  notes explain why; customer label falls back to ContactFirst/LastName
  for residential customers
- Fix future appointment title for residential customers (was always using
  CompanyName which is null for individuals)
- New SeedDataService.AiPredictions.cs: seeds 8 AiItemPrediction records
  (varied complexity/confidence/tags/reasoning) and attaches them to the
  first 8 eligible QuoteItems, marking those items IsAiItem=true; 3 of 8
  have UserOverrodeEstimate=true for AI Accuracy report demo
- SeedDataService.cs: wire SeedAiPredictionsAsync after Invoices
- Remove.cs: collect QuoteItem.AiPredictionId FKs before deleting items,
  then delete orphaned AiItemPrediction records after quotes are removed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:40:12 -04:00
spouliot dbd39a9fe5 Phase 3: AR/AP aging buckets, PO seeder, Bills vendor fix
- Bills.cs: replace aceHardware/fastenal lookups with grainger/harbor/localSupply
  to match Phase 1 vendor renames; update all vendor invoice number prefixes
- Bills.cs: add 3 AP aging-bucket bills (30-60, 61-90, 90+ days overdue) so all
  four AP aging buckets are populated for report demos
- Invoices.cs: add 3 more overdue invoices (31-60, 61-90, 90+ day AR buckets)
  alongside the existing 21-day overdue; total now 29 invoices
- New SeedDataService.PurchaseOrders.cs: 7 POs — 3 Received (historical), 2
  Submitted (in-flight), 2 Draft (pending approval); links to inventory items
  where available
- SeedDataService.cs: wire SeedPurchaseOrdersAsync after Vendors seeder
- Remove.cs: add PO + POItem cleanup inside Bills removal block (two-step ID
  fetch to avoid nested LINQ translation issues)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:29:31 -04:00
spouliot 584664e7c8 Demo seed Phase 2: workers, time entries, maintenance records
- 5 named shop workers seeded as ApplicationUser (Employee role):
  Mike Sanders (Coater), Jake Wilson (Sandblaster), Sarah Brooks (Inspector),
  Tyler Green (General), Chris Mason (Lead) — @pcldemo.com fingerprint domain
- Job time entries seeded for all in-progress and completed jobs;
  Worker Productivity report will have data from day one
- Maintenance history seeded per equipment: 2 completed records + 1 upcoming
  scheduled + 1 overdue record on Pressure Pot for overdue alert demo
- Equipment renamed to spec names: Main Batch Oven, Small Batch Oven, Powder
  Coating Booth, Blast Cabinet, Pressure Pot Blaster, Air Compressor, Wash
  Station, Forklift (replaced Overhead Conveyor which wasn't in spec)
- RemoveSeedDataOptions.Workers added; Remove.cs cleans up workers + time
  entries on Demo Reset; SeedDataController resets workers in ResetDemoCompany

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:20:04 -04:00
spouliot 1255bc0670 Demo seed Phase 1: NC identity, spec inventory, revenue targeting
- Customers: 10 NC commercial (Carolina Fabrication, Apex Motorsports, Triangle
  Offroad, Smith Welding, Raleigh Architectural Metals, etc.) + 17 residential,
  all anchored to Raleigh-Durham area for cohesive tutorial identity
- Inventory: 6 spec powders (Gloss Black, Matte Black, Super Chrome, Candy Red,
  Signal White, Illusion Purple) + 5 consumables (Tape, Silicone Plugs, Hooks,
  Acetone, Blast Media); 2 low-stock + 1 out-of-stock for dashboard alerts
- Vendors: updated to spec (Prismatic Powders, Columbia Coatings, Harbor Freight,
  Grainger, Local Industrial Supply)
- Quotes: 35 quotes (was 20) with 5-status distribution; dates span 5-6 months
- Jobs: 50 jobs (was ~32) with per-customer price ranges so Revenue by Customer
  report shows realistic Pareto curve (Carolina Fabrication largest, etc.)
- Remove.cs: fingerprints updated for all 27 new customer emails + 11 new SKUs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:12:47 -04:00
spouliot 01f6897d08 Scale demo seed data down for tutorial recordings
Customers: 100 → 27 (15 commercial across auto/industrial/architectural/
fitness/marine/energy, including 2 tax-exempt govts; 12 individuals)

Quotes: 75 → 20; date range extended to 4-6 months (was 90 days);
status distribution adjusted proportionally (2 draft, 3 sent, 10 approved,
3 rejected, 2 expired)

Jobs: fixed 50-loop → per-customer 0-5 jobs (~32 total); jobIdx cycles
all 16 statuses globally so every status is visible; creation dates spread
across 1-5 months for in-progress/early jobs, 2-6 months for completed jobs

SeededCustomerEmails updated to match new 27-customer set (added
gnelson@email.com and carol.evans@email.com)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:56:32 -04:00
spouliot 72382a5dd5 Fix demo reset: wipe bills/expenses, fix apostrophe display bug
- Add Bills and Expenses flags to RemoveSeedDataOptions
- RemoveSeedDataAsync: delete BillPayments + BillLineItems + Bills, then
  Expenses for the company when those flags are set
- ResetDemoCompany action: enable Bills=true and Expenses=true so all
  seeded AP data is cleared before re-seeding (was skipping on second reset)
- Fix apostrophe in success message (was &apos; in C# string, double-encoded
  by Razor to literal &apos; on screen)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:48:43 -04:00
spouliot 86a293a927 Add one-click Demo Company reset for tutorial recording prep
New ResetDemoCompany POST action wipes all seeded data (customers, jobs,
quotes, invoices, inventory, equipment, catalog, pricing tiers, operating
costs) from the DEMO company and immediately re-seeds with fresh records
dated relative to today. Seed data already used relative dates so every
reset produces a realistic, current-looking dataset.

View adds a red "Reset Demo Company" card at the top of the Seed Data page,
visible only when the DEMO company exists. Single button with confirm dialog;
shows exactly what will be wiped and what will be preserved (user accounts,
company settings, lookup tables).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:34:57 -04:00
spouliot 35264e6b2a Fix preferred powder selection and expand company settings export
- customer-details.js: encode double quotes in JSON.stringify output as &quot; so onclick attributes parse correctly when powder names contain double quotes
- ToolsController: add company_settings CSV to ExportAllCsv ZIP archive (was missing entirely)
- ToolsController: add ~30 missing fields to GenerateCompanySettingsCsv — AccountingMethod, timeclock settings, all shop capability/blast/coat rate fields, complexity surcharge percents, pricing mode, invoice number prefix, email-from fields, per-event notification flags, payment reminder settings, document accent colors/terms/footer notes, kiosk intake output
- Update GenerateCompanySettingsTemplate to match so import template stays in sync with export

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:12:49 -04:00
spouliot 0b839d0746 Fix Company Settings save, invoice PAID stamp, and purge script
- Company Settings: switch save button from type=submit to type=button
  to bypass HTML5 form validation blocking the submit event; replace
  AutoMapper Map() with explicit property assignment so EF change
  tracking reliably detects mutations; fix showButtonSuccess() never
  re-enabling the button after a successful save
- Invoice PDF: move PAID stamp into the header row as a centered middle
  column so it sits between the company and invoice blocks without
  adding height to the document
- Purge script: use business-date fields instead of CreatedAt so
  imported records (which all share today's CreatedAt) are correctly
  filtered by actual transaction dates

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:36:15 -04:00
spouliot 66c3febd7a Move invoice PAID stamp inline to header; add email to company block
- Remove watermark overlay layer; PAID badge now sits centered between
  the header row and the accent rule so it never obscures line items
- Add PrimaryContactEmail to company info block in header
- Remove ComposePaidStamp helper (no longer needed)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:39:11 -04:00
spouliot b8057295ec Redirect emails to dev address in non-production; fix PAID stamp color
- EmailService: add RedirectIfNonProd() mirroring SmsService pattern;
  reads SendGrid:DevRedirectEmail and redirects all outbound email in
  non-production so real customers are never contacted on local/dev
- appsettings.json: set DevRedirectEmail to spouliot@scppowdercoating.com
- PdfService: revert Opacity() (not in QuestPDF 2024.12.3); use
  Colors.Green.Lighten2 for stamp + border to achieve lighter look

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:32:01 -04:00
spouliot 14d6c82839 Make invoice PAID stamp smaller and semi-transparent
Reduce font 80→52, border 5→3, add 35% opacity so stamp no longer
obscures line items on dense invoices.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:27:33 -04:00
spouliot db4b73013a Simplify inventory label: combine header and scan hint, remove dashed footer
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:24:49 -04:00
spouliot e313149f08 Fix inventory label duplicate color/finish display
- Suppress ColorName line if it matches the item Name (powders use
  color name as their item name, causing it to show twice)
- Suppress Finish if already contained in the item Name
- Always show Manufacturer regardless of whether it is populated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 16:07:43 -04:00
spouliot 82fb48f7a5 Patch export/import for missing fields; add CustomerContacts export
- DataExportController + AccountDataExportController: add ProjectName to
  Jobs, Quotes, Invoices (XLSX + CSV); add LeadSource + ShipTo fields to
  Customers (XLSX + CSV); add CustomerContacts sheet/CSV (new)
- Both export views: add Customer Contacts checkbox (checked by default)
- CustomerImportDto: add LeadSource + ShipTo* fields
- JobImportDto: add ProjectName
- QuoteImportDto: add ProjectName
- InvoiceImportDto: add Project Name (dual-name alias for round-trip)
- CsvImportService: wire all new import fields to entity creation;
  also patch invoice update path for ProjectName
- Add scripts/purge_imported_data.sql (dry-run T-SQL for data cleanup)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:14:27 -04:00
spouliot 427c52a499 Fix Ready for Pickup filter returning no results
The Ready pill passed searchTerm=ReadyForPickup which did a text search —
"readyforpickup" (no spaces) never matched the display name "Ready for Pickup".
Converted to statusGroup=ready and added the corresponding controller case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:39:35 -04:00
spouliot d92266b027 Fix empty state showing Create First button when a filter is active
Jobs: use AllJobCount (global total) to distinguish truly-empty from
filter-returned-nothing; show Clear Filters button in the latter case.
Quotes: expand the filter-active check to include tagFilter and statusCode,
which were missing from the condition.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 14:20:20 -04:00
spouliot 750e1b1c5b Fix preferred powder search dropdown not appearing
Inline display:none!important on the results div blocked all CSS rules
from showing it, including the :not(:empty) trick. Switched to explicit
JS show/hide so the dropdown is reliably visible after typing 2+ chars.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:59:14 -04:00
spouliot 94a89ee175 Add CRM features: Additional Contacts, Lead Source, Ship-To Address; update Help docs
- New CustomerContact entity + migration (AddCustomerContactsAndCrmFields)
- Customer.LeadSource + ShipToAddress/City/State/ZipCode/Country fields
- Additional Contacts card on Customer Details with AJAX add/edit/delete
- Lead Source dropdown on Create/Edit; Ship-To section on Create/Edit
- Customer Details: side-by-side billing/ship-to when ship-to is set
- Help docs: Customers (contacts, ship-to, lead source, preferred powders, outstanding pickups)
- Help docs: Jobs (clone job, project name), Quotes (project name), Invoices (project name), Inventory (low stock clickable filter)
- HelpKnowledgeBase.cs updated for all features above

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 12:46:08 -04:00
spouliot 711cd01cd3 Add CRM features: Outstanding Pickups, Customer Notes, Clone Job, Preferred Powders
- Outstanding Pickups card on Customer Details shows jobs awaiting pickup with age badges
- Customer Notes log: inline add/delete notes with important flag, AJAX-backed
- Clone Job action on Jobs controller; Repeat Last Job button on Customer Details quick actions
- Preferred Powders per customer: typeahead inventory search, AJAX add/remove
- CustomerPreferredPowder entity + migration; unit tests for CRM stats/timeline logic
- Fix EF Core concurrency bug: parallel Task.WhenAll FindAsync replaced with sequential awaits

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:59:32 -04:00
spouliot 7cbae31916 Fix invoice ProjectName not pre-filling on edit; add to Details view
Edit GET now falls back to job.ProjectName for invoices created before the
column was added. Details view shows Project Name alongside Customer PO.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:58:09 -04:00
spouliot 9367e358d9 Add Project Name field to invoice create and edit forms
Stores ProjectName on the Invoice entity (previously only inherited from the
linked job at display time). Pre-fills from the job when creating from a job.
Migration: AddInvoiceProjectName.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 08:50:02 -04:00
spouliot 9f1460c9c0 Make Low Stock stat card clickable to filter inventory by low stock items
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 16:48:04 -04:00
spouliot 94e536178c Add optional Project Name field to quotes, jobs, and printed documents
- Add ProjectName (nvarchar 100, nullable) to Quote and Job entities;
  migration AddProjectNameToQuotesAndJobs applied
- Add ProjectName to all relevant DTOs: QuoteDto/Create/Update,
  JobDto/List/Create/Update, InvoiceDto (mapped from Job.ProjectName
  via AutoMapper so the invoice PDF picks it up without a separate column)
- Form field added after Customer PO in Quote Create/Edit and Job Create/Edit
- CreateJobFromQuote copies ProjectName from quote to job automatically
- Details views (Quote and Job) display Project when set
- Printable quote PDF: Project row in the quote details block
- Work order: Project row in customer/job info section
- Invoice PDF: Project shown in the Job Reference block alongside Job # and PO #

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 14:48:28 -04:00
spouliot 456d054229 Fix prospect quote conversion losing the job; add reply-to in email footer
QuotesController — ConvertToCustomer POST was wrongly setting the quote
status to 'Converted' (which means a job exists) and redirecting to the
customer page with no job created. The quote then disappeared from the
default list filter and the user had no way to create the job without
hunting for it. Fix: leave the quote at 'Approved' after customer
creation and redirect back to the quote details page with a toast
prompting the next step. 'Converted' status is now set exclusively by
CreateJobFromQuote when a job actually exists.

NotificationService — add tenant reply-to email address as a visible
line in the email footer so customers who ignore or whose mail client
doesn't honour the Reply-To header still have a clear address to contact.
Also adds Warning-level logging when no reply-to is configured for a
company so future routing issues are diagnosable from app logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-08 10:35:48 -04:00
spouliot f38a1e3273 Add Reply-To diagnostic logging to GetEmailFromAsync
Logs a Warning when no Reply-To email is configured for a company
(so the logs show why replies land at the platform sender address)
and a Debug entry when one is set, making future send issues
diagnosable without needing the SendGrid Activity API.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 11:08:45 -04:00
spouliot 03b425a12f Update blast rate tests to match nozzle-primary formula
Remove the CFM=0→zero test (CFM no longer in the formula path).
Update expected values to match the new nozzle-primary tables and
corrected TierDefaults CFM/nozzle pairings. Add WetBlasting and
RustAndScale substrate coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:21:39 -04:00
spouliot 8453449833 Recalibrate blast rate formula from industry reference tables
Replace the inaccurate CFM-based formula with nozzle-primary tables
sourced from industry standard abrasive blast cleaning references:
- Pressure pot: midpoints averaged from two reference tables
  (#4 nozzle: 115 sqft/hr, #5: 175 sqft/hr, etc.)
- Siphon cabinet: dedicated siphon cabinet reference table
  (#4 nozzle: 125 sqft/hr, #3: 75 sqft/hr, etc.)
- SiphonPot: 80% of pressure pot rate (open gravity feed, no enclosure)
- WetBlasting: 60% of pressure pot rate (water-media reduces velocity)

CFM is removed from the rate formula entirely — nozzle size determines
throughput and CFM draw, so CFM is a consequence of nozzle choice, not
an independent variable. Override field still bypasses formula for shops
that have measured their own throughput.

Also corrects TierDefaults nozzle/CFM pairings which were mismatched
(e.g. Small tier had 40 CFM assigned to a #5 nozzle that needs 150 CFM).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 16:12:45 -04:00
spouliot ad986561c9 Fix AI quote blast rate: single formula path, correct client preview
Root cause: company-settings-lookups.js had its own baseByCfm/multiplier
tables that were completely different from ShopCapabilityCalculator.cs,
so the UI showed an inflated rate (e.g. 82 sqft/hr) while the AI prompt
received the server-computed rate (e.g. 9 sqft/hr).

- Add CompanySettingsController.DeriveBlastRate endpoint — thin GET that
  calls ShopCapabilityCalculator directly; now the single formula path
- Delete all client-side formula code (baseByCfm, multiplier tables,
  deriveBlastRate) — ~30 lines removed
- Modal live preview calls /CompanySettings/DeriveBlastRate with 250ms
  debounce instead of computing locally
- Blast setup table uses setup.derivedRate from GetBlastSetups (already
  server-computed) instead of recalculating client-side
- QuotesController.AiAnalyzeItem: when no blast setup is explicitly
  selected, fall back to the company's default blast setup so the
  configured rate is always used

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:57:46 -04:00
spouliot 0d5553f3b2 Fix dark mode hover colors in coat/powder dropdown menus
Replace all hardcoded light-mode colors (#f0f4ff, #e8eeff, #fff8e1, #fff)
with Bootstrap CSS variables (--bs-secondary-bg, --bs-primary-bg-subtle,
--bs-warning-bg-subtle, --bs-body-bg) so dropdown containers and hover
states render correctly in both light and dark mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 15:12:24 -04:00
spouliot 87bbf158a4 Fix material usage logging: remaining weight mode, edit modal, and consolidate duplicate logic
- InventoryController: extract RecordInventoryUsageAsync helper; both LogUsage
  (scan page) and LogMaterial (jobs modal, moved from JobsController) call it —
  no more duplicate save/GL logic across two controllers
- Log Material modal: replace radio buttons with prominent toggle buttons so the
  active mode (Amount Used vs Amount Remaining) is always visually obvious; add
  always-visible preview line showing exactly what will be logged before saving
- Edit Usage modal: add quantity field (pre-populated from existing transaction)
  with delta adjustment to InventoryItem.QuantityOnHand on save; include
  completed/terminal jobs in the dropdown so entries can be corrected after a
  job is marked done
- Scan page job picker: include jobs completed within the last 7 days (marked
  with '(completed)') so usage can be logged after a job is finished

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 14:31:02 -04:00
spouliot f453a95f28 Add hover tooltips on job list rows showing description and PO number
Adds CustomerPO to JobListDto (maps by convention), then builds a
Bootstrap tooltip per row with description · PO: xxx, skipping blank
fields. Rows with neither get no tooltip. Helps identify jobs at a
glance without opening the details page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 18:53:52 -04:00
spouliot d9e98a55d2 Fix customer email inputs to allow comma-separated addresses
type="email" triggers jQuery Validate's email rule which rejects commas,
blocking multi-address input despite the multiple attribute. Switching to
type="text" defers validation to the server-side SplitEmails/MailAddress
logic in the DTO which already handles comma-separated lists correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 12:16:30 -04:00
spouliot 99deca3b62 Default imported formula templates to active regardless of export state
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:14:48 -04:00
spouliot 23e64829bb Fix formula export/import: embed fields as real JSON array, not escaped string
Previously FieldsJson was serialized as an escaped string in the export
file, which was fragile and unreadable. Now parsed into a JsonElement and
embedded as a proper JSON array under the key "fields". Import reads it
back with GetRawText() to reconstruct the stored string. This prevents
the null/empty fields bug caused by manually-edited or round-tripped files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 11:06:38 -04:00
spouliot cd4c233b60 Fix formula export casing: use camelCase to match import property lookups
System.Text.Json defaults to PascalCase for anonymous types, producing
"Name"/"OutputMode" etc., while the import used TryGetProperty("name")
causing every template to fail with "no name". Adding CamelCase naming
policy aligns the export format with what the import expects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:52:58 -04:00
spouliot 6c07216c64 Fix custom formula item pricing: multiply by quantity, not divide
ManualUnitPrice holds the per-item formula result. The previous code
incorrectly treated it as the batch total and divided by Quantity,
causing the unit price to shrink as quantity increased. Now follows
the same pattern as every other ManualUnitPrice path in this method.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 10:27:11 -04:00
spouliot b23bea6db0 Add formula template export/import and unsaved-changes guard
- Export: GET /CompanySettings/ExportCustomItemTemplates downloads all
  company templates as an indented JSON backup (strips internal IDs/paths)
- Import: POST /CompanySettings/ImportCustomItemTemplates restores from
  that file; runs full field + formula validation, skips name duplicates,
  returns per-item results (imported / skipped / errors)
- Unsaved-changes guard: cfModal now intercepts backdrop/ESC/X when the
  form is dirty and prompts before discarding work
- Export and Import buttons added to the Custom Formulas card header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 09:24:02 -04:00
136 changed files with 65288 additions and 2447 deletions
+122 -479
View File
@@ -1,134 +1,72 @@
# CLAUDE.md # CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Guidance for Claude Code when working in this repository.
## Project Overview ## Project Overview
A production-ready ASP.NET Core 8.0 MVC application for managing powder coating business operations. The application implements Clean Architecture with six projects across three layers (Domain, Application, Infrastructure) plus two presentation layers (Web MVC, RESTful API). ASP.NET Core 8.0 MVC application for powder coating business operations. Clean Architecture:
**Core** (entities/interfaces) → **Application** (DTOs/profiles) → **Infrastructure** (EF/repos/services) + **Web** (Razor MVC) + **Api** (REST/JWT).
## Essential Commands ## Essential Commands
### Building and Running
```bash ```bash
# Build entire solution # Build
dotnet build dotnet build
# Run web application (MVC) # Web MVC — https://localhost:58461
cd src/PowderCoating.Web cd src/PowderCoating.Web && dotnet run
dotnet run
# Access at: https://localhost:58461
# Run web with auto-reload # API — Swagger at root URL
dotnet watch run cd src/PowderCoating.Api && dotnet run
# Run API # Tests
cd src/PowderCoating.Api dotnet test
dotnet run dotnet test tests/PowderCoating.UnitTests
# Swagger UI at root URL dotnet test tests/PowderCoating.IntegrationTests
# Run tests
dotnet test # All tests
dotnet test tests/PowderCoating.UnitTests # Unit tests only
dotnet test tests/PowderCoating.IntegrationTests # Integration tests only
``` ```
### Database Operations ### Database (EF Core)
Run from `src/PowderCoating.Web`. **Always include `--context ApplicationDbContext`** — multiple DbContexts exist; omitting it throws.
```bash ```bash
# All EF commands run from Web project directory
cd src/PowderCoating.Web cd src/PowderCoating.Web
dotnet ef migrations add <Name> --project ../PowderCoating.Infrastructure --context ApplicationDbContext
# Create migration (must specify Infrastructure project) dotnet ef database update --project ../PowderCoating.Infrastructure --context ApplicationDbContext
dotnet ef migrations add MigrationName --project ../PowderCoating.Infrastructure dotnet ef migrations remove --project ../PowderCoating.Infrastructure --context ApplicationDbContext
dotnet ef migrations list --project ../PowderCoating.Infrastructure --context ApplicationDbContext
# Apply migrations dotnet ef database drop --project ../PowderCoating.Infrastructure --context ApplicationDbContext
dotnet ef database update --project ../PowderCoating.Infrastructure
# Reset database (WARNING: deletes all data)
dotnet ef database drop --project ../PowderCoating.Infrastructure
dotnet ef database update --project ../PowderCoating.Infrastructure
# List migrations
dotnet ef migrations list --project ../PowderCoating.Infrastructure
# Remove last migration (if not applied)
dotnet ef migrations remove --project ../PowderCoating.Infrastructure
``` ```
### Default Credentials ### Default Credentials
``` ```
SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123! SuperAdmin (break glass): artemis@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123! SuperAdmin (seed): superadmin@powdercoatinglogix.com / SuperAdmin123!
SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123! SuperAdmin (seed): spouliot@powdercoatinglogix.com / SuperAdmin123!
Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123! Company Admin (seed): demo@powdercoatinglogix.com / CompanyAdmin123!
``` ```
## Architecture Overview ## Architecture
### Clean Architecture Layers ### Layers
- **Core** — Entities, enums, repository + service interfaces. `BaseEntity` provides `Id`, `CompanyId`, `CreatedAt`, `UpdatedAt`, `IsDeleted`, audit fields on every entity.
- **Application** — DTOs, AutoMapper profiles (auto-discovered via `cfg.AddMaps()`; `PricingTierProfile` is an exception — registered manually in `Program.cs`), service interfaces. No UI/infra deps.
- **Infrastructure** — `ApplicationDbContext`, `Repository<T>`, `UnitOfWork`. Seed data is **manual only** via Platform Management → Seed Data.
- **Web** — Razor MVC + Bootstrap 5. **Api** — JWT Bearer, Swagger.
**Domain Layer (PowderCoating.Core)** ### Global Query Filters (always active)
- Contains business entities, enums, and repository interfaces - Soft deletes: `IsDeleted == false`
- `BaseEntity` provides common properties for all entities (Id, CompanyId, CreatedAt, UpdatedAt, IsDeleted, audit fields) - Multi-tenancy: non-SuperAdmin sees only their `CompanyId`
- All entities inherit from BaseEntity and support soft delete - Bypass: `ignoreQueryFilters: true` on repository methods
- No dependencies on other projects
**Application Layer (PowderCoating.Application)** **Critical:** global filters are not sufficient on their own. Every `FindAsync`/`GetAllAsync` in a controller must also include an explicit `CompanyId == currentCompanyId` predicate — defense in depth.
- DTOs organized by domain (Customer, Job, Equipment, Inventory, Maintenance)
- AutoMapper profiles with reverse mappings
- Service interfaces (IFileService, etc.)
- No UI or infrastructure dependencies
**Infrastructure Layer (PowderCoating.Infrastructure)**
- `ApplicationDbContext` with global query filters for soft deletes and multi-tenancy
- Generic `Repository<T>` implementing `IRepository<T>`
- `UnitOfWork` implementing `IUnitOfWork` with lazy-loaded repositories
- Seed data is triggered **manually** via Platform Management → Seed Data (not automatic on startup)
**Presentation Layers**
- `PowderCoating.Web`: MVC application with Razor views, Bootstrap 5 UI
- `PowderCoating.Api`: RESTful API with JWT authentication, Swagger documentation
### Key Design Patterns
**Repository Pattern**
- Generic `Repository<T>` in Infrastructure
- All CRUD operations, search, pagination, eager loading support
- Soft delete with `SoftDeleteAsync()` method
**Unit of Work Pattern**
- Coordinates multiple repositories
- Transaction support: `BeginTransactionAsync()`, `CommitTransactionAsync()`, `RollbackTransactionAsync()`
- Lazy instantiation of repositories
- `SaveChangesAsync()` or `CompleteAsync()` to persist changes
**Dependency Injection**
- All dependencies registered in `Program.cs`
- Controllers inject `IUnitOfWork` and `IMapper`
- Services are scoped to request lifetime
**Global Query Filters**
- Soft deletes: All queries automatically filter `IsDeleted == false`
- Multi-tenancy: Non-SuperAdmin users see only their company data
- Bypass with `ignoreQueryFilters: true` parameter in repository methods
### Multi-Tenancy Implementation
- `CompanyId` foreign key on all business entities
- `ITenantContext` injected into DbContext resolves current company
- SuperAdmin role can view all companies
- Global query filters enforce company isolation at database level
- Users have both system role (SuperAdmin) and company role (CompanyAdmin, Manager, Worker, Viewer)
## Data Access Rules (ENFORCE THESE) ## Data Access Rules (ENFORCE THESE)
> **`ApplicationDbContext` is NEVER injected into a controller.** > **`ApplicationDbContext` is NEVER injected into a controller.**
> All data access in controllers goes through `IUnitOfWork`. No exceptions outside the list below. > All data access goes through `IUnitOfWork`. Enforced at startup by `EnforceDataAccessArchitecture()` in `Program.cs`.
> **This rule is enforced at startup:** `EnforceDataAccessArchitecture()` in `Program.cs` scans all > Full rationale + permanent exceptions: `docs/DATA_ACCESS_ARCHITECTURE.md`
> controllers at boot and throws if any non-exempt controller injects `ApplicationDbContext`.
> Full rationale and permanent exceptions list: `docs/DATA_ACCESS_ARCHITECTURE.md`
### Three tiers — use the right one: ### Three tiers — use the right one:
@@ -141,346 +79,57 @@ await _unitOfWork.CompleteAsync();
**Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork` **Tier 2 — Complex domain queries** → typed repositories on `IUnitOfWork`
```csharp ```csharp
// Include chains and domain-specific queries belong in the repository, not the controller
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id); var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id); var invoice = await _unitOfWork.Invoices.LoadForViewAsync(id);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token); var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
``` ```
Typed repositories: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, Typed repos: `IJobRepository`, `IInvoiceRepository`, `IQuoteRepository`, `ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository`
`ICustomerRepository`, `IBillRepository`, `IPurchaseOrderRepository` — defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`.
— defined in `Core/Interfaces/Repositories/`, implemented in `Infrastructure/Repositories/`
**Tier 3 — Aggregate/reporting queries** → injected read services **Tier 3 — Aggregate/reporting** → injected read services
```csharp ```csharp
// P&L, AR aging, cycle time, powder usage — shaped DTOs, never tracked entities
var aging = await _financialReports.GetArAgingAsync(companyId); var aging = await _financialReports.GetArAgingAsync(companyId);
``` ```
Services: `IFinancialReportService`, `IOperationalReportService` Services: `IFinancialReportService`, `IOperationalReportService`.
— defined in `Core/Interfaces/Services/`, implemented in `Infrastructure/Services/`
### Permanent exceptions (ApplicationDbContext allowed — intentional, documented): ### Permanent exceptions (ApplicationDbContext allowed — intentional):
`StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`, `StripeWebhookController`, `WebhooksController`, `PaymentController`, `RegistrationController`,
`DataExportController`, `AccountDataExportController`, `DataPurgeController`, `DataExportController`, `AccountDataExportController`, `DataPurgeController`,
`SystemInfoController`, `SystemLogsController`, `CompanyHealthController` `SystemInfoController`, `SystemLogsController`, `CompanyHealthController`
If you think you need a new exception, you almost certainly don't. Check the spec first. ---
## Domain Concepts
### Job Lifecycle
16 statuses in `JobStatusLookup` **table — NOT an enum**: Pending → Quoted → Approved → InPreparation → Sandblasting → MaskingTaping → Cleaning → InOven → Coating → Curing → QualityCheck → Completed → ReadyForPickup → Delivered | OnHold | Cancelled.
Use `.Include(j => j.JobStatus)` and filter on `!j.JobStatus.IsTerminalStatus`.
**Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI).
### Customers
- **Commercial**: B2B, pricing tiers, credit limits
- **Non-Commercial**: individual/residential
### Inventory
Transactions tracked in `InventoryTransaction` (Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial). Reorder points trigger alerts.
### Equipment & Maintenance
Equipment: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired.
Maintenance priority: Low/Normal/High/Critical. Status: Scheduled/InProgress/Completed/Cancelled/Overdue.
--- ---
## Data Access Patterns ## Pricing
### Common Controller Pattern ### Key Rules
- Custom powder (no inventory item + `PowderToOrder > 0`): charge for the **full ordered quantity**
```csharp - In-stock powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
public class ExampleController : Controller - Tax-exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote/invoice create; marked ★ in dropdowns
{
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public ExampleController(IUnitOfWork unitOfWork, IMapper mapper)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
}
public async Task<IActionResult> Index()
{
var entities = await _unitOfWork.Examples.GetAllAsync();
var dtos = _mapper.Map<List<ExampleDto>>(entities);
return View(dtos);
}
[HttpPost]
public async Task<IActionResult> Create(CreateExampleDto dto)
{
var entity = _mapper.Map<Example>(dto);
await _unitOfWork.Examples.AddAsync(entity);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
public async Task<IActionResult> Delete(int id)
{
await _unitOfWork.Examples.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
return RedirectToAction(nameof(Index));
}
}
```
### Using Unit of Work Repositories
All entity repositories are available via `IUnitOfWork` properties:
- `_unitOfWork.Customers`
- `_unitOfWork.Jobs`
- `_unitOfWork.JobItems`
- `_unitOfWork.Quotes`
- `_unitOfWork.InventoryItems`
- `_unitOfWork.Equipment`
- `_unitOfWork.MaintenanceRecords`
- Plus additional entities (Suppliers, JobPhotos, JobNotes, etc.)
### Eager Loading Related Data
```csharp
// Load customer with related data
var customer = await _unitOfWork.Customers.GetByIdAsync(
id,
c => c.Jobs,
c => c.Quotes,
c => c.PricingTier
);
// Find with predicate and includes
var activeJobs = await _unitOfWork.Jobs.FindAsync(
j => j.Status != JobStatus.Completed,
j => j.Customer,
j => j.JobItems
);
```
### Pagination
```csharp
var pagedJobs = await _unitOfWork.Jobs.GetPagedAsync(
pageNumber: 1,
pageSize: 25,
j => j.Status == JobStatus.InPreparation,
j => j.Customer
);
```
## Important Domain Concepts
### Job Lifecycle
Jobs progress through 16 statuses:
1. **Pending** → Initial state
2. **Quoted** → Quote generated
3. **Approved** → Customer approved
4. **InPreparation** → Job prep started
5. **Sandblasting** → Surface prep
6. **MaskingTaping** → Masking areas
7. **Cleaning** → Pre-coat cleaning
8. **InOven** → Pre-heating
9. **Coating** → Applying powder
10. **Curing** → Heat curing
11. **QualityCheck** → Inspection
12. **Completed** → Work finished
13. **ReadyForPickup** → Awaiting customer
14. **Delivered** → Job delivered
15. **OnHold** → Paused
16. **Cancelled** → Cancelled
**Job Priorities**: Low, Normal, High, Urgent, Rush (color-coded in UI)
### Customer Types
- **Commercial**: B2B customers with pricing tiers, credit limits
- **Non-Commercial**: Individual customers, typically simpler pricing
### Inventory Management
**Transaction Types**: Purchase, Sale, Adjustment, Transfer, Return, Waste, Initial
- All transactions tracked in `InventoryTransaction` entity
- Reorder points trigger low-stock alerts
### Equipment & Maintenance
**Equipment Status**: Operational, NeedsMaintenance, UnderMaintenance, OutOfService, Retired
**Maintenance Priority**: Low, Normal, High, Critical
**Maintenance Status**: Scheduled, InProgress, Completed, Cancelled, Overdue
## Configuration Files
### Web Application (src/PowderCoating.Web/appsettings.json)
```json
{
"ConnectionStrings": {
"DefaultConnection": "Server=.\\SQLEXPRESS;Database=PowderCoatingDb;Trusted_Connection=true;MultipleActiveResultSets=true;TrustServerCertificate=true"
},
"AppSettings": {
"CompanyName": "Powder Coating Logix",
"DefaultQuoteValidityDays": 30,
"DefaultPaymentTerms": "Net 30",
"TaxRate": 0.0,
"Currency": "USD",
"TrialPeriodDays": 7,
"QuoteApprovalTokenDays": 30
},
"AI": {
"Anthropic": {
"ApiKey": "your-anthropic-api-key-here"
}
},
"SendGrid": { ... },
"Stripe": { ... },
"Storage": { ... }
}
```
**AI uses Anthropic Claude Sonnet 4.6** (`claude-sonnet-4-6`) — NOT OpenAI. The `AI:Anthropic:ApiKey` config key is what the AI photo quoting and AI scheduling services read.
### API (src/PowderCoating.Api/appsettings.json)
```json
{
"JwtSettings": {
"SecretKey": "CHANGE-THIS-TO-YOUR-OWN-SECRET-KEY-AT-LEAST-32-CHARACTERS",
"Issuer": "PowderCoatingAPI",
"Audience": "PowderCoatingMobileApp",
"ExpirationMinutes": 1440
}
}
```
### Launch Settings (src/PowderCoating.Web/Properties/launchSettings.json)
Default ports:
- HTTPS: 58461
- HTTP: 58462
## Authentication & Authorization
### System Roles
- **SuperAdmin**: Platform-wide access, sees all companies and deleted records
- **Administrator**: Company admin
- **Manager**: Operations management
- **Employee**: Create/edit jobs and quotes
- **ShopFloor**: Update job status
- **ReadOnly**: View-only access
### Custom Authorization Policies
Defined in `PowderCoating.Shared/Constants/AppConstants.cs`:
- `RequireAdministratorRole`
- `CanManageJobs`
- `CanManageInventory`
- `CanManageUsers`
- `CanViewData`
Apply with `[Authorize(Policy = "PolicyName")]` on controllers/actions.
### JWT Authentication (API Only)
API uses JWT Bearer tokens. Web uses cookie-based Identity authentication.
## AutoMapper Configuration
AutoMapper is registered as singleton in `Program.cs`:
```csharp
builder.Services.AddSingleton(provider => new MapperConfiguration(cfg =>
{
cfg.AddMaps(typeof(ApplicationAssemblyMarker).Assembly);
}).CreateMapper());
```
All profiles in `Application/Mappings/` are auto-discovered. Profiles include reverse mappings for entity ↔ DTO conversion.
## Logging
Serilog configured to write:
- Console (structured logs)
- File: `logs/powdercoating-{Date}.txt` (rolling daily)
Access via constructor injection:
```csharp
private readonly ILogger<ExampleController> _logger;
```
## Common Development Tasks
### Adding a New Entity
1. Create entity class in `Core/Entities/` inheriting from `BaseEntity`
2. Add DbSet to `ApplicationDbContext`
3. Register repository property in `IUnitOfWork` interface
4. Add lazy-loaded property in `UnitOfWork` implementation
5. Create migration: `dotnet ef migrations add AddEntityName --project ../PowderCoating.Infrastructure`
6. Apply migration: `dotnet ef database update --project ../PowderCoating.Infrastructure`
### Adding a New Controller
1. Create DTOs in `Application/DTOs/`
2. Create AutoMapper profile in `Application/Mappings/`
3. Create controller in `Web/Controllers/`
4. Create views in `Web/Views/[ControllerName]/`
5. Add navigation link in `Views/Shared/_Layout.cshtml`
### Working with Soft Deletes
```csharp
// Soft delete (sets IsDeleted = true)
await _unitOfWork.Customers.SoftDeleteAsync(id);
await _unitOfWork.CompleteAsync();
// Physical delete (use sparingly)
await _unitOfWork.Customers.DeleteAsync(entity);
await _unitOfWork.CompleteAsync();
// Include deleted records in query
var allCustomers = await _unitOfWork.Customers.GetAllAsync(ignoreQueryFilters: true);
```
### Bypassing Multi-Tenancy Filters
Only for SuperAdmin users:
```csharp
// See all companies' data
var allJobs = await _unitOfWork.Jobs.GetAllAsync(ignoreQueryFilters: true);
```
## Implemented Modules
All modules below are fully implemented with controllers, views, and migrations applied.
### Operations
- **Jobs** — full lifecycle (16 statuses), worker assignment, time entries, rework tracking, shop access codes, job templates
- **Quotes** — multi-item pricing engine, AI Photo Quoting (Anthropic Claude Sonnet 4.6), quote-to-job conversion, customer approval portal, online payment
- **Invoices** — create from job, partial payments, voids, PDF download, email send; 1:1 Job→Invoice enforced by unique index
- **Deposits** — record against customer/job/quote; auto-applied to invoices on creation; receipt PDF via QuestPDF
- **Customers** — commercial and non-commercial types, pricing tiers, tax exempt flag + certificate upload, credit limits
- **Oven Scheduler** — batch jobs into named ovens, capacity planning, suggested batches
### Inventory & Purchasing
- **Inventory** — stock tracking, transactions, reorder alerts, powder coverage/efficiency fields
- **Vendors** — supplier management, payment terms, linked to inventory items
- **Purchase Orders** — create/submit/receive POs, convert to vendor bills
- **Accounts Payable** — vendor bills, AP ledger, payment tracking
### Shop Management
- **Shop Workers** — roles (Coater, Sandblaster, etc.), assignment to jobs and maintenance tasks
- **Equipment & Maintenance** — equipment status lifecycle, scheduled/completed maintenance records
- **Catalog Items** — pre-priced service catalog with default prices
- **Pricing Tiers** — customer discount tiers; use `CompanyAdminOnly` policy (not `RequireAdministratorRole`)
### Billing & Payments
- **Stripe** — subscription plans, checkout sessions, customer portal, webhooks (`/stripe/webhook`)
- **Stripe Connect** — embedded payments, OAuth flow for tenant onboarding
- **Twilio SMS** — `ISmsService` fully implemented; webhook at `POST /Webhooks/TwilioSms`
### Platform (SuperAdmin only)
- **Platform Users** — create/manage SuperAdmin accounts
- **Companies** — view/manage all tenant companies
- **Seed Data** — manual seeding via Platform Management UI (not automatic)
- **Subscription Plans** — `SubscriptionPlanConfig` controls per-plan limits and pricing
### Other
- **Help Center** — 14 fully-written articles at `Views/Help/`
- **Setup Wizard** — 10-step onboarding wizard at `SetupWizardController`
- **Reports** — 24 report actions including P&L, AR Aging, Powder Usage, Job Cycle Time, PDF exports
- **Gift Certificates** — issue, redeem, track balance
- **Announcements** — platform-wide announcements to tenants
### Key Pricing Rules
- Custom powder (no inventory item + `PowderToOrder` > 0): charge for the **full ordered quantity**, not just calculated usage
- In-stock inventory powder: charge for calculated usage only (surface area × lbs/sqft × unit cost)
- Tax exempt customers (`Customer.IsTaxExempt`): `TaxPercent` defaults to 0 on quote and invoice create; customer dropdown marks exempt customers with ★
### Pricing Routing Flags — Must Stay In Sync Across All Three Layers ### Pricing Routing Flags — Must Stay In Sync Across All Three Layers
`PricingCalculationService.CalculateQuoteItemPriceAsync` routes each item to the correct pricing path using boolean flags. **These flags MUST exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, AND be mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.** `PricingCalculationService.CalculateQuoteItemPriceAsync` routes via boolean flags. **Must exist identically on `QuoteItem`, `JobItem`, and `CreateQuoteItemDto`, mapped in all three `JobItemAssemblyService.CreateJobItem` overloads.**
| Flag | Effect if missing on JobItem | | Flag | Effect if missing on JobItem |
|------|------------------------------| |------|------------------------------|
@@ -489,83 +138,77 @@ All modules below are fully implemented with controllers, views, and migrations
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate | | `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math | | `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
**Checklist when adding a new pricing routing flag:** **Checklist when adding a new flag:**
1. Add the property to `QuoteItem` (Core/Entities) 1. Add to `QuoteItem` (Core/Entities)
2. Add the property to `JobItem` (Core/Entities) 2. Add to `JobItem` (Core/Entities)
3. Add it to `CreateQuoteItemDto` (Application/DTOs) 3. Add to `CreateQuoteItemDto` (Application/DTOs)
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService) 4. Add to `JobItemSeed` (private class in `JobItemAssemblyService`)
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads 5. Map in all three `JobItemAssemblyService.CreateJobItem` overloads
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem` 6. Include in every `existingItemsData` JSON block in `Edit.cshtml`, `EditItems.cshtml`, and all controller actions that build `CreateQuoteItemDto` from a `JobItem`
7. Add a migration if the field is new on a persisted entity 7. Add migration if field is new on a persisted entity
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 13 are done — this is intentional 8. Structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` fails until steps 13 are done — intentional
### Branding ---
- Application name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — used in sidebar header (when no tenant logo), login/register pages, sidebar footer
- Sidebar footer always shows PCL logo linking to `http://www.powdercoatinglogix.com`
- Tenant companies can upload their own logo (stored in Azure Blob `companylogos` container); it replaces the PCL logo in the sidebar header
## Known Issues ## Configuration
- Entity Framework warnings about global query filters on related entities (non-critical, informational only) ### Key Settings (`src/PowderCoating.Web/appsettings.json`)
- DB: `ConnectionStrings:DefaultConnection` (SQL Server Express)
- AI: `AI:Anthropic:ApiKey`**Anthropic Claude `claude-sonnet-4-6`, NOT OpenAI**
- Ports: HTTPS 58461 / HTTP 58462
## File Upload Configuration ### Auth & Roles
- Web: cookie-based ASP.NET Identity. API: JWT Bearer.
- System roles: SuperAdmin, Administrator, Manager, Employee, ShopFloor, ReadOnly
- Policies in `AppConstants.cs`: `RequireAdministratorRole`, `CanManageJobs`, `CanManageInventory`, `CanManageUsers`, `CanViewData`, `CompanyAdminOnly`
- **PricingTiers use `CompanyAdminOnly` — NOT `RequireAdministratorRole`** (that policy is unregistered and will throw)
Limits defined in `AppConstants.cs`: ### File Uploads
- Max file size: 10 MB Limits in `AppConstants.cs`: 10 MB max, allowed: jpg/jpeg/png/gif/pdf/doc/docx/xls/xlsx.
- Allowed extensions: jpg, jpeg, png, gif, pdf, doc, docx, xls, xlsx
## Testing Strategy ---
- **Unit Tests**: Test business logic in isolation ## UI Rules
- **Integration Tests**: Test full request pipeline with test database
- Use xUnit framework
- Mock `IUnitOfWork` in unit tests
## Extending the System - **HTML entities in `.cshtml`** — `&mdash;` not `—`, `&times;` not `×`, `&hellip;` not `…`. Literal Unicode gets corrupted by AI tools + Windows file encoding.
- **External JS files only** — put scripts in `wwwroot/js/*.js`, reference via `src=`. Inline `@section Scripts` blocks can silently fail with SyntaxErrors from layout HTML context.
- **`alert-permanent` CSS class** — `_Layout` auto-dismisses `.alert:not(.alert-permanent)` after ~5s. Any non-toast alert that must persist needs this class.
- **SignalR hubs already in place**: `NotificationHub``/hubs/notifications` (company-scoped), `ShopHub``/hubs/shop` (shop floor).
### Adding AI Features ---
AI uses Anthropic Claude Sonnet 4.6 via `IAiQuoteService`. Configure the key under `AI:Anthropic:ApiKey` in `appsettings.json`. ## Gotchas
1. Create service interface in `Application/Interfaces/`
2. Implement in `Infrastructure/Services/` calling the Anthropic client
3. Inject into controllers via DI
### SignalR Hubs - **Two data export controllers**: `DataExportController` (SuperAdmin) and `AccountDataExportController` (company self-service). When changing CSV columns, fix **both**.
- **Help docs**: when a feature changes, update both `HelpKnowledgeBase.cs` (AI assistant knowledge) and the matching article in `Views/Help/` (human-readable help center).
- **Demo reset**: `DemoController.ResetDemoData` is gated on `company.CompanyCode == "DEMO"` — only the demo tenant can trigger a reset. ForceRemoveAll wipes all company data before reseeding.
- **artemis@ account**: the "break glass" root SuperAdmin — guards in `PlatformUsersController` protecting it are intentional, never remove them.
Two hubs are already implemented and mapped in `Program.cs`: ---
- `NotificationHub``/hubs/notifications` (company-scoped push notifications)
- `ShopHub``/hubs/shop` (real-time shop floor updates)
To add a new hub: ## Implemented Modules
1. Create hub class in `Web/Hubs/`
2. Map hub in `Program.cs`: `app.MapHub<YourHub>("/hubpath")`
3. Use JavaScript client in views to connect
### Adding API Endpoints All fully implemented with controllers, views, and migrations applied.
1. Create controller in `Api/Controllers/` with `[ApiController]` attribute **Operations**: Jobs (16 statuses, worker assignment, time entries, rework, shop codes, templates) · Quotes (AI Photo Quoting via Anthropic, quote→job conversion, customer approval portal) · Invoices (1:1 Job→Invoice unique index; partial payments, void, PDF, email) · Deposits (auto-applied on invoice create; QuestPDF receipt) · Customers (commercial/non-commercial, pricing tiers, tax-exempt + cert upload) · Oven Scheduler (named ovens, capacity, suggested batches)
2. Return `ActionResult<T>` types
3. Use `[Authorize]` for protected endpoints
4. Document with XML comments for Swagger
## Project Dependencies **Inventory & Purchasing**: Inventory (transactions, reorder alerts, powder coverage/efficiency) · Vendors · Purchase Orders (create/submit/receive, convert to bills) · Accounts Payable (bills, AP ledger, payment tracking)
Key NuGet packages: **Shop Management**: Shop Workers (roles, job/maintenance assignment) · Equipment & Maintenance · Catalog Items · Pricing Tiers
- **AutoMapper 16.0.0**: Entity-to-DTO mapping
- **Entity Framework Core 8.0.11**: ORM and database access
- **Serilog.AspNetCore 8.0.3**: Structured logging
- **Microsoft.AspNetCore.Identity.UI 8.0.11**: Authentication
- **Swashbuckle.AspNetCore 7.2.0**: API documentation (API project)
## Security Considerations **Billing**: Stripe (subscriptions, checkout sessions, webhooks `/stripe/webhook`) · Stripe Connect (embedded payments, OAuth) · Twilio SMS (`ISmsService`; webhook `POST /Webhooks/TwilioSms`)
- Password requirements: 8+ chars, uppercase, lowercase, digit **Platform (SuperAdmin)**: Platform Users · Companies · Seed Data (manual only) · Subscription Plans (`SubscriptionPlanConfig`)
- HTTPS enforced in production
- SQL injection prevented by EF Core parameterization
- XSS protection via Razor encoding
- CSRF tokens on all forms (automatic with ASP.NET Core)
- Sensitive settings (connection strings, API keys) should use User Secrets in development and Azure Key Vault in production
## Active design work **Other**: Help Center (14 articles at `Views/Help/`) · Setup Wizard (10-step, `SetupWizardController`) · Reports (24 actions: P&L, AR Aging, Powder Usage, Cycle Time, PDF exports) · Gift Certificates · Announcements · In-App Notification Bell · Passkey/Biometric Login (WebAuthn, Fido2NetLib) · Customer Intake Kiosk (iPad, SignalR push, `KioskSession`) · AI Accounting Features (receipt scan, AR follow-up, smart categorization, cash flow forecast, anomaly detection)
A visual redesign is in progress. If the user asks about UI changes, dashboard/jobs/board styling, or the new design tokens, read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/CLAUDE.md` for that work.
---
## Branding
- App name: **Powder Coating Logix**
- PCL logo: `wwwroot/images/pcl-logo.png` — sidebar header (when no tenant logo), login/register, sidebar footer (always)
- Sidebar footer always links to `http://www.powdercoatinglogix.com`
- Tenant logos: Azure Blob `companylogos` container; replaces PCL logo in sidebar header only
## Active Design Work
A visual redesign is in progress. For UI changes, dashboard/jobs/board styling, or design tokens: read `design_handoff_pcl_redesign/README.md` and follow `design_handoff_pcl_redesign/CLAUDE.md`.
+8
View File
@@ -8,4 +8,12 @@
--> -->
<NoWarn>$(NoWarn);NU1605</NoWarn> <NoWarn>$(NoWarn);NU1605</NoWarn>
</PropertyGroup> </PropertyGroup>
<!--
TRACKED: System.Security.Cryptography.Xml 8.0.2 has two High advisories (GHSA-37gx-xxp4-5rgx,
GHSA-w3x6-4m5h-cxqf — XML signature vulnerabilities). No patched version exists in the NuGet
feed as of 2026-06-14; 9.0.0 (the only higher version) is also flagged. Re-check when a
patched 8.x or 9.x build ships and pin here. Pulled in transitively by one of: Fido2, EPPlus,
Azure SDK, or VisualStudio.Web.CodeGeneration.Design.
-->
</Project> </Project>
+431
View File
@@ -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
@@ -0,0 +1,64 @@
using System.ComponentModel.DataAnnotations;
namespace PowderCoating.Application.DTOs.Customer;
public class CustomerContactDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
public string FirstName { get; set; } = string.Empty;
public string? LastName { get; set; }
public string? Title { get; set; }
public string? ContactRole { get; set; }
public string? Email { get; set; }
public string? Phone { get; set; }
public string? MobilePhone { get; set; }
public string? Notes { get; set; }
public string DisplayName => string.IsNullOrWhiteSpace(LastName) ? FirstName : $"{FirstName} {LastName}";
}
public class CreateCustomerContactDto
{
[Required(ErrorMessage = "First name is required.")]
[StringLength(100)]
[Display(Name = "First Name")]
public string FirstName { get; set; } = string.Empty;
[StringLength(100)]
[Display(Name = "Last Name")]
public string? LastName { get; set; }
[StringLength(100)]
[Display(Name = "Job Title")]
public string? Title { get; set; }
[StringLength(50)]
[Display(Name = "Role")]
public string? ContactRole { get; set; }
[EmailAddress]
[StringLength(200)]
[Display(Name = "Email")]
public string? Email { get; set; }
[Phone]
[StringLength(20)]
[Display(Name = "Phone")]
public string? Phone { get; set; }
[Phone]
[StringLength(20)]
[Display(Name = "Mobile Phone")]
public string? MobilePhone { get; set; }
[StringLength(500)]
[Display(Name = "Notes")]
public string? Notes { get; set; }
}
public class UpdateCustomerContactDto : CreateCustomerContactDto
{
public int Id { get; set; }
public int CustomerId { get; set; }
}
@@ -0,0 +1,35 @@
namespace PowderCoating.Application.DTOs.Customer;
/// <summary>A single entry in the customer activity timeline feed on the Details page.</summary>
public class CustomerTimelineEventDto
{
public DateTime Date { get; set; }
public string Icon { get; set; } = string.Empty;
public string BadgeColor { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string? Subtitle { get; set; }
public decimal? Amount { get; set; }
public int? EntityId { get; set; }
public string? LinkController { get; set; }
public string? LinkAction { get; set; }
}
/// <summary>Aggregate lifetime metrics displayed in the CRM stats card on Customer Details.</summary>
public class CustomerLifetimeStatsDto
{
public int TotalJobs { get; set; }
public int ActiveJobs { get; set; }
/// <summary>Sum of Total on non-voided invoices.</summary>
public decimal TotalRevenue { get; set; }
/// <summary>Sum of AmountPaid on non-voided invoices.</summary>
public decimal TotalCollected { get; set; }
/// <summary>Mean FinalPrice across all jobs for this customer.</summary>
public decimal AverageJobValue { get; set; }
public DateTime? LastJobDate { get; set; }
public int? DaysSinceLastJob { get; set; }
public int TotalQuotes { get; set; }
public int TotalInvoices { get; set; }
public decimal OpenBalance { get; set; }
/// <summary>Id of the most recent job — used by the "Repeat Last Job" button on Customer Details.</summary>
public int? LastJobId { get; set; }
}
@@ -36,6 +36,16 @@ public class CustomerDto
public bool NotifyBySms { get; set; } public bool NotifyBySms { get; set; }
public DateTime? SmsConsentedAt { get; set; } public DateTime? SmsConsentedAt { get; set; }
public string? SmsConsentMethod { get; set; } public string? SmsConsentMethod { get; set; }
// CRM
public string? LeadSource { get; set; }
// Ship-to address
public string? ShipToAddress { get; set; }
public string? ShipToCity { get; set; }
public string? ShipToState { get; set; }
public string? ShipToZipCode { get; set; }
public string? ShipToCountry { get; set; }
} }
public class CreateCustomerDto : IValidatableObject public class CreateCustomerDto : IValidatableObject
@@ -115,6 +125,31 @@ public class CreateCustomerDto : IValidatableObject
[StringLength(2000)] [StringLength(2000)]
public string? GeneralNotes { get; set; } public string? GeneralNotes { get; set; }
[Display(Name = "How did you find us?")]
[StringLength(100)]
public string? LeadSource { get; set; }
// Ship-to / alternate address
[Display(Name = "Ship-To Street Address")]
[StringLength(500)]
public string? ShipToAddress { get; set; }
[Display(Name = "City")]
[StringLength(100)]
public string? ShipToCity { get; set; }
[Display(Name = "State")]
[StringLength(50)]
public string? ShipToState { get; set; }
[Display(Name = "Zip Code")]
[StringLength(20)]
public string? ShipToZipCode { get; set; }
[Display(Name = "Country")]
[StringLength(100)]
public string? ShipToCountry { get; set; }
[Display(Name = "Notify by Email")] [Display(Name = "Notify by Email")]
public bool NotifyByEmail { get; set; } = true; public bool NotifyByEmail { get; set; } = true;
@@ -228,12 +228,31 @@ public class PowderOrderVendorGroupDto
public List<PowderOrderLineDto> Lines { get; set; } = new(); public List<PowderOrderLineDto> Lines { get; set; } = new();
} }
public class PowderOrderLineDto /// <summary>
/// One job's contribution to a merged powder order line.
/// </summary>
public class PowderOrderJobRefDto
{ {
public int CoatId { get; set; }
public int JobId { get; set; } public int JobId { get; set; }
public string JobNumber { get; set; } = string.Empty; public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
public decimal LbsToOrder { get; set; }
}
public class PowderOrderLineDto
{
/// <summary>All coat IDs contributing to this line (&gt;1 when multiple jobs need the same powder).</summary>
public List<int> CoatIds { get; set; } = new();
/// <summary>Per-job breakdown; parallel to CoatIds.</summary>
public List<PowderOrderJobRefDto> Jobs { get; set; } = new();
// Convenience accessors for single-coat scenarios (the "placed" panel is always per-coat).
public int CoatId => CoatIds.FirstOrDefault();
public int JobId => Jobs.FirstOrDefault()?.JobId ?? 0;
public string JobNumber => Jobs.FirstOrDefault()?.JobNumber ?? string.Empty;
public string CustomerName => Jobs.FirstOrDefault()?.CustomerName ?? string.Empty;
public string CoatName { get; set; } = string.Empty; public string CoatName { get; set; } = string.Empty;
public string? ColorName { get; set; } public string? ColorName { get; set; }
public string? ColorCode { get; set; } public string? ColorCode { get; set; }
@@ -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; }
@@ -49,6 +49,9 @@ public class JobImportDto
[Name("SpecialInstructions")] [Name("SpecialInstructions")]
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
[Name("ProjectName")]
public string? ProjectName { get; set; }
[Name("Notes")] [Name("Notes")]
public string? Notes { get; set; } public string? Notes { get; set; }
} }
@@ -38,6 +38,9 @@ public class QuoteImportDto
[Name("ExpirationDate")] [Name("ExpirationDate")]
public DateTime? ExpirationDate { get; set; } public DateTime? ExpirationDate { get; set; }
[Name("ProjectName")]
public string? ProjectName { get; set; }
[Name("Subtotal")] [Name("Subtotal")]
public decimal Subtotal { get; set; } public decimal Subtotal { get; set; }
@@ -57,6 +57,7 @@ public class InvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? ExternalReference { get; set; } public string? ExternalReference { get; set; }
public int? SalesTaxAccountId { get; set; } public int? SalesTaxAccountId { get; set; }
public string? SalesTaxAccountName { get; set; } public string? SalesTaxAccountName { get; set; }
@@ -88,6 +89,7 @@ public class CreateInvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary> /// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
public decimal EarlyPaymentDiscountPercent { get; set; } public decimal EarlyPaymentDiscountPercent { get; set; }
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary> /// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
@@ -105,6 +107,7 @@ public class UpdateInvoiceDto
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Terms { get; set; } public string? Terms { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new(); public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
} }
@@ -52,6 +52,7 @@ public class JobDto
public decimal DiscountValue { get; set; } public decimal DiscountValue { get; set; }
public string? DiscountReason { get; set; } public string? DiscountReason { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? SpecialInstructions { get; set; } public string? SpecialInstructions { get; set; }
public string? InternalNotes { get; set; } public string? InternalNotes { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
@@ -113,6 +114,8 @@ public class JobListDto
public string? CustomerEmail { get; set; } public string? CustomerEmail { get; set; }
public bool CustomerNotifyByEmail { get; set; } = true; public bool CustomerNotifyByEmail { get; set; } = true;
public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public DateTime? ScheduledDate { get; set; } public DateTime? ScheduledDate { get; set; }
public DateTime? DueDate { get; set; } public DateTime? DueDate { get; set; }
public decimal FinalPrice { get; set; } public decimal FinalPrice { get; set; }
@@ -166,6 +169,7 @@ public class CreateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")] [StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")] [Display(Name = "Customer PO")]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")] [Display(Name = "Special Instructions")]
@@ -251,6 +255,7 @@ public class UpdateJobDto
[StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")] [StringLength(100, ErrorMessage = "Customer PO cannot exceed 100 characters")]
[Display(Name = "Customer PO")] [Display(Name = "Customer PO")]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")] [StringLength(2000, ErrorMessage = "Special instructions cannot exceed 2000 characters")]
[Display(Name = "Special Instructions")] [Display(Name = "Special Instructions")]
@@ -107,6 +107,7 @@ public class QuoteDto
public string? Terms { get; set; } public string? Terms { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
public string? Tags { get; set; } public string? Tags { get; set; }
// Items // Items
@@ -234,6 +235,7 @@ public class CreateQuoteDto
[Display(Name = "Customer PO Number")] [Display(Name = "Customer PO Number")]
[StringLength(50)] [StringLength(50)]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")] [Display(Name = "Tags")]
[StringLength(500)] [StringLength(500)]
@@ -376,6 +378,7 @@ public class UpdateQuoteDto
[Display(Name = "Customer PO Number")] [Display(Name = "Customer PO Number")]
[StringLength(50)] [StringLength(50)]
public string? CustomerPO { get; set; } public string? CustomerPO { get; set; }
public string? ProjectName { get; set; }
[Display(Name = "Tags")] [Display(Name = "Tags")]
[StringLength(500)] [StringLength(500)]
@@ -45,6 +45,19 @@ public class RemoveSeedDataOptions
public bool Catalog { get; set; } public bool Catalog { get; set; }
public bool PricingTiers { get; set; } public bool PricingTiers { get; set; }
public bool OperatingCosts { get; set; } public bool OperatingCosts { get; set; }
public bool Bills { get; set; }
public bool Expenses { get; set; }
public bool Workers { get; set; }
public bool Vendors { get; set; }
public bool NamedOvens { get; set; }
public bool Appointments { get; set; }
/// <summary>
/// When true, all removal blocks skip fingerprint matching and delete by CompanyId only.
/// Use for demo resets where the goal is a full wipe regardless of which code version seeded
/// the data. Never set this on a real tenant company.
/// </summary>
public bool ForceRemoveAll { get; set; }
} }
public class SeedDataResult public class SeedDataResult
@@ -41,5 +41,12 @@ public class CustomerProfile : Profile
opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName) opt => opt.MapFrom(src => !string.IsNullOrEmpty(src.ContactFirstName) || !string.IsNullOrEmpty(src.ContactLastName)
? $"{src.ContactFirstName} {src.ContactLastName}".Trim() ? $"{src.ContactFirstName} {src.ContactLastName}".Trim()
: string.Empty)); : string.Empty));
// CustomerContact
CreateMap<CustomerContact, CustomerContactDto>();
CreateMap<CreateCustomerContactDto, CustomerContact>();
CreateMap<UpdateCustomerContactDto, CustomerContact>()
.ForMember(dest => dest.Id, opt => opt.Ignore()); // Id is set by the controller, not mapped
CreateMap<CustomerContact, UpdateCustomerContactDto>();
} }
} }
@@ -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
@@ -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);
});
}
}); });
} }
@@ -299,15 +299,14 @@ public class PricingCalculationService : IPricingCalculationService
} }
// Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side // Custom formula items (FixedRate mode): the wizard evaluated the NCalc formula server-side
// and stored the result as ManualUnitPrice. The formula result IS the total price — it already // and stored the per-item result as ManualUnitPrice. Multiply by Quantity for the total,
// incorporates any quantity-like fields the user entered (e.g. numWheels, numParts). Do NOT // exactly like every other item type that uses ManualUnitPrice.
// multiply by Quantity again; doing so double-counts when the formula itself accounts for qty.
// SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored // SurfaceAreaSqFt mode: ManualUnitPrice is null; the formula produced sqft which was stored
// in SurfaceAreaSqFt, so the item falls through to the standard calculated path below. // in SurfaceAreaSqFt, so the item falls through to the standard calculated path below.
if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue) if (item.IsCustomFormulaItem && item.ManualUnitPrice.HasValue)
{ {
var formulaTotal = item.ManualUnitPrice.Value; var formulaUnitPrice = item.ManualUnitPrice.Value;
var formulaUnitPrice = item.Quantity > 0 ? formulaTotal / item.Quantity : formulaTotal; var formulaTotal = formulaUnitPrice * item.Quantity;
return new QuoteItemPricingResult return new QuoteItemPricingResult
{ {
MaterialCost = 0, MaterialCost = 0,
@@ -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
}; };
} }
@@ -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; }
}
@@ -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).
+1
View File
@@ -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; }
+1
View File
@@ -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
@@ -152,6 +152,20 @@ public class CustomerNote : BaseEntity
public virtual Customer Customer { get; set; } = null!; public virtual Customer Customer { get; set; } = null!;
} }
/// <summary>
/// Records an inventory item as a preferred powder for a specific customer.
/// Shown on Customer Details for faster quoting of repeat orders.
/// </summary>
public class CustomerPreferredPowder : BaseEntity
{
public int CustomerId { get; set; }
public int InventoryItemId { get; set; }
public string? Notes { get; set; }
public virtual Customer Customer { get; set; } = null!;
public virtual InventoryItem InventoryItem { get; set; } = null!;
}
public class JobStatusHistory : BaseEntity public class JobStatusHistory : BaseEntity
{ {
public int JobId { get; set; } public int JobId { get; set; }
@@ -43,6 +43,8 @@ public interface IUnitOfWork : IDisposable
IJobPhotoRepository JobPhotos { get; } IJobPhotoRepository JobPhotos { get; }
IRepository<JobNote> JobNotes { get; } IRepository<JobNote> JobNotes { get; }
IRepository<CustomerNote> CustomerNotes { get; } IRepository<CustomerNote> CustomerNotes { get; }
IRepository<CustomerContact> CustomerContacts { get; }
IRepository<CustomerPreferredPowder> CustomerPreferredPowders { get; }
IRepository<JobStatusHistory> JobStatusHistory { get; } IRepository<JobStatusHistory> JobStatusHistory { get; }
IRepository<PricingTier> PricingTiers { get; } IRepository<PricingTier> PricingTiers { get; }
@@ -135,7 +137,7 @@ IRepository<ReworkRecord> ReworkRecords { get; }
IPlainRepository<Announcement> Announcements { get; } IPlainRepository<Announcement> Announcements { get; }
IPlainRepository<BannedIp> BannedIps { get; } IPlainRepository<BannedIp> BannedIps { get; }
IPlainRepository<DashboardTip> DashboardTips { get; } IPlainRepository<DashboardTip> DashboardTips { get; }
IRepository<InAppNotification> InAppNotifications { get; } IInAppNotificationRepository InAppNotifications { get; }
IPlainRepository<ReleaseNote> ReleaseNotes { get; } IPlainRepository<ReleaseNote> ReleaseNotes { get; }
// Bug Reports // Bug Reports
@@ -13,37 +13,39 @@ public interface IBillRepository : IRepository<Bill>
/// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and /// APAccount, LineItems (filtered to non-deleted) with Account and Job navigations, and
/// Payments (filtered to non-deleted) with BankAccount. Returns null if not found. /// Payments (filtered to non-deleted) with BankAccount. Returns null if not found.
/// </summary> /// </summary>
Task<Bill?> LoadForViewAsync(int id); Task<Bill?> LoadForViewAsync(int id, int companyId);
/// <summary> /// <summary>
/// Loads a single bill with only its line items for the Edit form. Excludes payment /// Loads a single bill with only its line items for the Edit form. Excludes payment
/// navigations since those are read-only after the bill is opened. /// navigations since those are read-only after the bill is opened.
/// </summary> /// </summary>
Task<Bill?> LoadForEditAsync(int id); Task<Bill?> LoadForEditAsync(int id, int companyId);
/// <summary> /// <summary>
/// Returns all bills for the Index/AP ledger view filtered by status and/or search term. /// Returns all bills for the Index/AP ledger view filtered by status and/or search term.
/// Includes Vendor so the list row can display vendor name without a second round trip. /// Includes Vendor so the list row can display vendor name without a second round trip.
/// LineItems are included for the search-in-description condition only. /// LineItems are included for the search-in-description condition only.
/// </summary> /// </summary>
Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount); Task<List<Bill>> GetForIndexAsync(int companyId, string? statusFilter, string? searchTerm, decimal? searchAmount);
/// <summary> /// <summary>
/// Returns the last bill number with the given prefix (including soft-deleted records) for /// Returns the last bill number with the given prefix (including soft-deleted records) for
/// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted. /// sequential number generation. Uses IgnoreQueryFilters so deleted bills are counted.
/// Scoped to <paramref name="companyId"/> so sequences are per-tenant.
/// </summary> /// </summary>
Task<string?> GetLastBillNumberAsync(string prefix); Task<string?> GetLastBillNumberAsync(int companyId, string prefix);
/// <summary> /// <summary>
/// Returns the last payment number with the given prefix (including soft-deleted records) /// Returns the last payment number with the given prefix (including soft-deleted records)
/// for sequential payment reference generation. /// for sequential payment reference generation.
/// Scoped to <paramref name="companyId"/> so sequences are per-tenant.
/// </summary> /// </summary>
Task<string?> GetLastPaymentNumberAsync(string prefix); Task<string?> GetLastPaymentNumberAsync(int companyId, string prefix);
/// <summary> /// <summary>
/// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>, /// Returns all non-deleted bills whose <c>BillDate</c> falls within [<paramref name="start"/>,
/// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded. /// <paramref name="end"/>], with Vendor, LineItems → Account, and Payments loaded.
/// Used by the accounting data export to produce QuickBooks IIF / CSV files. /// Scoped to <paramref name="companyId"/>. Used by the accounting data export.
/// </summary> /// </summary>
Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end); Task<List<Bill>> GetForDateRangeAsync(int companyId, DateTime start, DateTime end);
} }
@@ -12,7 +12,7 @@ public interface ICustomerRepository : IRepository<Customer>
/// Loads a single customer with the navigations needed by the Details view: PricingTier, /// Loads a single customer with the navigations needed by the Details view: PricingTier,
/// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted. /// and recent CustomerNotes ordered newest-first. Returns null if not found or soft-deleted.
/// </summary> /// </summary>
Task<Customer?> LoadForDetailsAsync(int id); Task<Customer?> LoadForDetailsAsync(int id, int companyId);
/// <summary> /// <summary>
/// Finds a customer by email address within the current tenant. Used for duplicate-email /// Finds a customer by email address within the current tenant. Used for duplicate-email
@@ -0,0 +1,32 @@
using PowderCoating.Core.Entities;
namespace PowderCoating.Core.Interfaces.Repositories;
/// <summary>
/// Typed repository for <see cref="InAppNotification"/> providing DB-level pagination and
/// bounded reads for the bell-dropdown and notification history page. The generic
/// <see cref="IRepository{T}"/> returns materialized lists so ordering/limiting must happen
/// in C#; these methods push ORDER BY, SKIP, and TAKE into SQL where they belong.
/// </summary>
public interface IInAppNotificationRepository : IRepository<InAppNotification>
{
/// <summary>
/// Returns a page of notifications ordered newest-first, plus the total un-paged count.
/// SuperAdmin path filters to CompanyId == 0 (platform notifications only).
/// </summary>
Task<(List<InAppNotification> Items, int TotalCount)> GetPagedAsync(
bool isPlatformAdmin, int pageNumber, int pageSize);
/// <summary>
/// Returns the <paramref name="take"/> most recent notifications (read and unread)
/// for the bell dropdown. SuperAdmin path is scoped to platform notifications.
/// </summary>
Task<List<InAppNotification>> GetRecentAsync(bool isPlatformAdmin, int take = 20);
/// <summary>
/// Returns the <paramref name="take"/> most recent unread notifications plus the full
/// unread count for the bell badge. SuperAdmin path is scoped to platform notifications.
/// </summary>
Task<(List<InAppNotification> Items, int UnreadCount)> GetUnreadAsync(
bool isPlatformAdmin, int take = 20);
}
@@ -16,7 +16,7 @@ public interface IInvoiceRepository : IRepository<Invoice>
/// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions. /// Refunds with IssuedBy, CreditApplications with CreditMemo, and GiftCertificateRedemptions.
/// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted. /// Filtered includes exclude soft-deleted children. Returns null if not found or soft-deleted.
/// </summary> /// </summary>
Task<Invoice?> LoadForViewAsync(int id); Task<Invoice?> LoadForViewAsync(int id, int companyId);
/// <summary> /// <summary>
/// Returns the invoice linked to a job, or null if none exists. Pass /// Returns the invoice linked to a job, or null if none exists. Pass
@@ -22,26 +22,26 @@ public interface IJobRepository : IRepository<Job>
/// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices. /// and all JobItems with their Coats (InventoryItem + Vendor) and PrepServices.
/// Also loads JobPrepServices (job-level prep) separately. Returns null if not found. /// Also loads JobPrepServices (job-level prep) separately. Returns null if not found.
/// </summary> /// </summary>
Task<Job?> LoadForDetailsAsync(int id); Task<Job?> LoadForDetailsAsync(int id, int companyId);
/// <summary> /// <summary>
/// Loads a single job with the include chain required by the Edit form: same as /// Loads a single job with the include chain required by the Edit form: same as
/// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and /// <see cref="LoadForDetailsAsync"/> but without the read-only audit navigations, and
/// with tracking enabled so changes can be saved. /// with tracking enabled so changes can be saved.
/// </summary> /// </summary>
Task<Job?> LoadForEditAsync(int id); Task<Job?> LoadForEditAsync(int id, int companyId);
/// <summary> /// <summary>
/// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump). /// Loads the lightweight job record needed for status-change operations (MoveCard, StatusBump).
/// Includes only JobStatus. Returns null if not found or soft-deleted. /// Includes only JobStatus. Returns null if not found or soft-deleted.
/// </summary> /// </summary>
Task<Job?> LoadForStatusChangeAsync(int id); Task<Job?> LoadForStatusChangeAsync(int id, int companyId);
/// <summary> /// <summary>
/// Returns the change history for a job, ordered newest-first, with ChangedBy navigation /// Returns the change history for a job, ordered newest-first, with ChangedBy navigation
/// loaded. Used by the Details view changelog tab. /// loaded. Used by the Details view changelog tab.
/// </summary> /// </summary>
Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId); Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId, int companyId);
/// <summary> /// <summary>
/// Returns the last job number that starts with <paramref name="prefix"/> for the given /// Returns the last job number that starts with <paramref name="prefix"/> for the given
@@ -84,7 +84,7 @@ public interface IJobRepository : IRepository<Job>
/// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>. /// into a new <see cref="JobTemplate"/> via <c>SaveJobAsTemplate</c>.
/// Returns null if not found or soft-deleted. /// Returns null if not found or soft-deleted.
/// </summary> /// </summary>
Task<Job?> LoadForTemplateSnapshotAsync(int jobId); Task<Job?> LoadForTemplateSnapshotAsync(int jobId, int companyId);
/// <summary> /// <summary>
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null, /// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
@@ -96,6 +96,7 @@ public interface IJobRepository : IRepository<Job>
/// <summary> /// <summary>
/// Returns the count of rework jobs linked to <paramref name="originalJobId"/> /// Returns the count of rework jobs linked to <paramref name="originalJobId"/>
/// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined. /// (including soft-deleted) so the next rework suffix (R1, R2, …) can be determined.
/// Scoped to <paramref name="companyId"/> to prevent cross-tenant count collisions.
/// </summary> /// </summary>
Task<int> GetReworkJobCountAsync(int originalJobId); Task<int> GetReworkJobCountAsync(int originalJobId, int companyId);
} }
@@ -12,22 +12,22 @@ namespace PowderCoating.Core.Interfaces.Repositories;
public interface INotificationLogRepository : IRepository<NotificationLog> public interface INotificationLogRepository : IRepository<NotificationLog>
{ {
/// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary> /// <summary>Returns the most recent notification log entry for the given invoice, or null.</summary>
Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId); Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId, int companyId);
/// <summary>Returns all notification log entries for the given invoice, newest-first.</summary> /// <summary>Returns all notification log entries for the given invoice, newest-first.</summary>
Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId); Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId, int companyId);
/// <summary>Returns the most recent notification log entry for the given quote, or null.</summary> /// <summary>Returns the most recent notification log entry for the given quote, or null.</summary>
Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId); Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId, int companyId);
/// <summary>Returns all notification log entries for the given quote, newest-first.</summary> /// <summary>Returns all notification log entries for the given quote, newest-first.</summary>
Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId); Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId, int companyId);
/// <summary>Returns the most recent notification log entry for the given job, or null.</summary> /// <summary>Returns the most recent notification log entry for the given job, or null.</summary>
Task<NotificationLog?> GetLatestForJobAsync(int jobId); Task<NotificationLog?> GetLatestForJobAsync(int jobId, int companyId);
/// <summary>Returns all notification log entries for the given job, newest-first.</summary> /// <summary>Returns all notification log entries for the given job, newest-first.</summary>
Task<List<NotificationLog>> GetAllForJobAsync(int jobId); Task<List<NotificationLog>> GetAllForJobAsync(int jobId, int companyId);
/// <summary> /// <summary>
/// Returns a paginated, filtered, and sorted page of notification log entries with Customer, /// Returns a paginated, filtered, and sorted page of notification log entries with Customer,
@@ -17,7 +17,7 @@ public interface IQuoteRepository : IRepository<Quote>
/// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep). /// CatalogItem, and PrepServices; plus QuotePrepServices (quote-level prep).
/// Returns null if not found or soft-deleted. /// Returns null if not found or soft-deleted.
/// </summary> /// </summary>
Task<Quote?> LoadForDetailsAsync(int id); Task<Quote?> LoadForDetailsAsync(int id, int companyId);
/// <summary> /// <summary>
/// Loads a single quote by its customer-facing approval token. Ignores global query filters /// Loads a single quote by its customer-facing approval token. Ignores global query filters
@@ -29,7 +29,7 @@ public interface IQuoteRepository : IRepository<Quote>
/// <summary> /// <summary>
/// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded. /// Returns the change history for a quote, ordered newest-first, with ChangedBy loaded.
/// </summary> /// </summary>
Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId); Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId, int companyId);
/// <summary> /// <summary>
/// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the /// Returns aggregate stat counts and total value for the Index view stat cards, scoped to the
@@ -43,7 +43,7 @@ public interface IQuoteRepository : IRepository<Quote>
/// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/> /// PDF generation and quote→job conversion. Cheaper than <see cref="LoadForDetailsAsync"/>
/// because it skips the parent-quote navigations that callers already have. /// because it skips the parent-quote navigations that callers already have.
/// </summary> /// </summary>
Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId); Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId, int companyId);
/// <summary> /// <summary>
/// Returns the last quote number that starts with <paramref name="prefix"/> for the given /// Returns the last quote number that starts with <paramref name="prefix"/> for the given
@@ -230,6 +230,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
public DbSet<JobNote> JobNotes { get; set; } public DbSet<JobNote> JobNotes { get; set; }
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary> /// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
public DbSet<CustomerNote> CustomerNotes { get; set; } public DbSet<CustomerNote> CustomerNotes { get; set; }
/// <summary>Additional contacts (billing, ops, drop-off) associated with a customer; tenant-filtered with soft delete.</summary>
public DbSet<CustomerContact> CustomerContacts { get; set; }
/// <summary>Inventory items marked as frequently used for a customer; shown on Customer Details for faster quoting.</summary>
public DbSet<CustomerPreferredPowder> CustomerPreferredPowders { get; set; }
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary> /// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
public DbSet<JobStatusHistory> JobStatusHistory { get; set; } public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary> /// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
@@ -551,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 =>
@@ -1025,6 +1031,17 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
modelBuilder.Entity<AuditLog>() modelBuilder.Entity<AuditLog>()
.HasIndex(a => new { a.EntityType, a.EntityId }); .HasIndex(a => new { a.EntityType, a.EntityId });
// InAppNotification — bell endpoint fires on every page load; compound indexes let SQL
// evaluate the tenant + soft-delete filter then sort/count without a full-table scan.
modelBuilder.Entity<InAppNotification>()
.HasIndex(n => new { n.CompanyId, n.IsDeleted, n.CreatedAt });
modelBuilder.Entity<InAppNotification>()
.HasIndex(n => new { n.CompanyId, n.IsDeleted, n.IsRead });
// ContactSubmission — SuperAdmin inbox; filter + sort covered by a single index.
modelBuilder.Entity<ContactSubmission>()
.HasIndex(c => new { c.CompanyId, c.IsDeleted, c.CreatedAt });
// Announcements — no tenant filter; visible based on Target logic in app layer // Announcements — no tenant filter; visible based on Target logic in app layer
modelBuilder.Entity<AnnouncementDismissal>() modelBuilder.Entity<AnnouncementDismissal>()
.HasOne(d => d.Announcement) .HasOne(d => d.Announcement)
@@ -1719,6 +1736,23 @@ modelBuilder.Entity<Job>()
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt }) .HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt"); .HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
modelBuilder.Entity<CustomerPreferredPowder>()
.HasIndex(p => new { p.CustomerId, p.InventoryItemId })
.IsUnique()
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
modelBuilder.Entity<CustomerPreferredPowder>()
.HasOne(p => p.Customer)
.WithMany()
.HasForeignKey(p => p.CustomerId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CustomerPreferredPowder>()
.HasOne(p => p.InventoryItem)
.WithMany()
.HasForeignKey(p => p.InventoryItemId)
.OnDelete(DeleteBehavior.Restrict);
// =================================================================== // ===================================================================
// END PERFORMANCE OPTIMIZATION INDEXES // END PERFORMANCE OPTIMIZATION INDEXES
// =================================================================== // ===================================================================
@@ -2242,7 +2276,9 @@ modelBuilder.Entity<Job>()
if (entry.State == EntityState.Added) if (entry.State == EntityState.Added)
{ {
entity.CreatedAt = DateTime.UtcNow; // Only stamp if not already set — seeders set historical dates and rely on them being preserved.
if (entity.CreatedAt == default)
entity.CreatedAt = DateTime.UtcNow;
entity.CreatedBy = currentUser; entity.CreatedBy = currentUser;
// Auto-set CompanyId for new entities (if not already set) // Auto-set CompanyId for new entities (if not already set)
@@ -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));
}
}
}
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));
}
}
}
@@ -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));
}
}
}
@@ -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));
}
}
}
@@ -0,0 +1,88 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PowderCoating.Infrastructure.Migrations
{
/// <inheritdoc />
public partial class AddNotificationAndContactIndexes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
migrationBuilder.CreateIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_CreatedAt",
table: "InAppNotifications",
columns: new[] { "CompanyId", "IsDeleted", "CreatedAt" });
migrationBuilder.CreateIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_IsRead",
table: "InAppNotifications",
columns: new[] { "CompanyId", "IsDeleted", "IsRead" });
migrationBuilder.CreateIndex(
name: "IX_ContactSubmissions_CompanyId_IsDeleted_CreatedAt",
table: "ContactSubmissions",
columns: new[] { "CompanyId", "IsDeleted", "CreatedAt" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_CreatedAt",
table: "InAppNotifications");
migrationBuilder.DropIndex(
name: "IX_InAppNotifications_CompanyId_IsDeleted_IsRead",
table: "InAppNotifications");
migrationBuilder.DropIndex(
name: "IX_ContactSubmissions_CompanyId_IsDeleted_CreatedAt",
table: "ContactSubmissions");
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 1,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9129));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 2,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9137));
migrationBuilder.UpdateData(
table: "PricingTiers",
keyColumn: "Id",
keyValue: 3,
column: "CreatedAt",
value: new DateTime(2026, 6, 10, 16, 17, 57, 120, DateTimeKind.Utc).AddTicks(9138));
}
}
}
@@ -2514,6 +2514,8 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
b.ToTable("ContactSubmissions"); b.ToTable("ContactSubmissions");
}); });
@@ -2818,6 +2820,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<DateTime?>("LastContactDate") b.Property<DateTime?>("LastContactDate")
.HasColumnType("datetime2"); .HasColumnType("datetime2");
b.Property<string>("LeadSource")
.HasColumnType("nvarchar(max)");
b.Property<string>("MobilePhone") b.Property<string>("MobilePhone")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2836,6 +2841,21 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<int?>("PricingTierId") b.Property<int?>("PricingTierId")
.HasColumnType("int"); .HasColumnType("int");
b.Property<string>("ShipToAddress")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToCity")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToCountry")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToState")
.HasColumnType("nvarchar(max)");
b.Property<string>("ShipToZipCode")
.HasColumnType("nvarchar(max)");
b.Property<string>("SmsConsentMethod") b.Property<string>("SmsConsentMethod")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -2894,6 +2914,81 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("Customers"); b.ToTable("Customers");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<string>("ContactRole")
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("CustomerId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("FirstName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("LastName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("MobilePhone")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Notes")
.HasMaxLength(500)
.HasColumnType("nvarchar(500)");
b.Property<string>("Phone")
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<string>("Title")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.ToTable("CustomerContacts");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b => modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2944,6 +3039,58 @@ namespace PowderCoating.Infrastructure.Migrations
b.ToTable("CustomerNotes"); b.ToTable("CustomerNotes");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<int>("CompanyId")
.HasColumnType("int");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<string>("CreatedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("CustomerId")
.HasColumnType("int");
b.Property<DateTime?>("DeletedAt")
.HasColumnType("datetime2");
b.Property<string>("DeletedBy")
.HasColumnType("nvarchar(max)");
b.Property<int>("InventoryItemId")
.HasColumnType("int");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<string>("Notes")
.HasColumnType("nvarchar(max)");
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<string>("UpdatedBy")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.HasIndex("InventoryItemId");
b.HasIndex("CustomerId", "InventoryItemId")
.IsUnique()
.HasDatabaseName("IX_CustomerPreferredPowders_CustomerId_InventoryItemId");
b.ToTable("CustomerPreferredPowders");
});
modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b => modelBuilder.Entity("PowderCoating.Core.Entities.DashboardTip", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -3845,6 +3992,10 @@ namespace PowderCoating.Infrastructure.Migrations
b.HasIndex("QuoteId"); b.HasIndex("QuoteId");
b.HasIndex("CompanyId", "IsDeleted", "CreatedAt");
b.HasIndex("CompanyId", "IsDeleted", "IsRead");
b.ToTable("InAppNotifications"); b.ToTable("InAppNotifications");
}); });
@@ -4269,6 +4420,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PreparedById") b.Property<string>("PreparedById")
.HasColumnType("nvarchar(450)"); .HasColumnType("nvarchar(450)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("PublicViewToken") b.Property<string>("PublicViewToken")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -4560,6 +4714,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<string>("PricingBreakdownJson") b.Property<string>("PricingBreakdownJson")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<int?>("QuoteId") b.Property<int?>("QuoteId")
.HasColumnType("int"); .HasColumnType("int");
@@ -7053,7 +7210,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 1, Id = 1,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9377), CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191),
Description = "Standard pricing for regular customers", Description = "Standard pricing for regular customers",
DiscountPercent = 0m, DiscountPercent = 0m,
IsActive = true, IsActive = true,
@@ -7064,7 +7221,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 2, Id = 2,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9381), CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196),
Description = "5% discount for preferred customers", Description = "5% discount for preferred customers",
DiscountPercent = 5m, DiscountPercent = 5m,
IsActive = true, IsActive = true,
@@ -7075,7 +7232,7 @@ namespace PowderCoating.Infrastructure.Migrations
{ {
Id = 3, Id = 3,
CompanyId = 0, CompanyId = 0,
CreatedAt = new DateTime(2026, 6, 1, 12, 29, 35, 841, DateTimeKind.Utc).AddTicks(9382), CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197),
Description = "10% discount for premium customers", Description = "10% discount for premium customers",
DiscountPercent = 10m, DiscountPercent = 10m,
IsActive = true, IsActive = true,
@@ -7385,6 +7542,9 @@ namespace PowderCoating.Infrastructure.Migrations
b.Property<decimal>("ProfitPercent") b.Property<decimal>("ProfitPercent")
.HasColumnType("decimal(18,2)"); .HasColumnType("decimal(18,2)");
b.Property<string>("ProjectName")
.HasColumnType("nvarchar(max)");
b.Property<string>("ProspectAddress") b.Property<string>("ProspectAddress")
.HasColumnType("nvarchar(max)"); .HasColumnType("nvarchar(max)");
@@ -9474,6 +9634,17 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("PricingTier"); b.Navigation("PricingTier");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerContact", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
.WithMany("CustomerContacts")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b => modelBuilder.Entity("PowderCoating.Core.Entities.CustomerNote", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer") b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
@@ -9485,6 +9656,25 @@ namespace PowderCoating.Infrastructure.Migrations
b.Navigation("Customer"); b.Navigation("Customer");
}); });
modelBuilder.Entity("PowderCoating.Core.Entities.CustomerPreferredPowder", b =>
{
b.HasOne("PowderCoating.Core.Entities.Customer", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("PowderCoating.Core.Entities.InventoryItem", "InventoryItem")
.WithMany()
.HasForeignKey("InventoryItemId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Customer");
b.Navigation("InventoryItem");
});
modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b => modelBuilder.Entity("PowderCoating.Core.Entities.Deposit", b =>
{ {
b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice") b.HasOne("PowderCoating.Core.Entities.Invoice", "AppliedToInvoice")
@@ -10980,6 +11170,8 @@ namespace PowderCoating.Infrastructure.Migrations
modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b => modelBuilder.Entity("PowderCoating.Core.Entities.Customer", b =>
{ {
b.Navigation("CustomerContacts");
b.Navigation("CustomerNotes"); b.Navigation("CustomerNotes");
b.Navigation("Invoices"); b.Navigation("Invoices");
@@ -15,10 +15,10 @@ public class BillRepository : Repository<Bill>, IBillRepository
public BillRepository(ApplicationDbContext context) : base(context) { } public BillRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Bill?> LoadForViewAsync(int id) public async Task<Bill?> LoadForViewAsync(int id, int companyId)
{ {
return await _context.Bills return await _context.Bills
.Where(b => b.Id == id && !b.IsDeleted) .Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
.Include(b => b.Vendor) .Include(b => b.Vendor)
.Include(b => b.APAccount) .Include(b => b.APAccount)
.Include(b => b.LineItems.Where(li => !li.IsDeleted)) .Include(b => b.LineItems.Where(li => !li.IsDeleted))
@@ -31,21 +31,21 @@ public class BillRepository : Repository<Bill>, IBillRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Bill?> LoadForEditAsync(int id) public async Task<Bill?> LoadForEditAsync(int id, int companyId)
{ {
return await _context.Bills return await _context.Bills
.Where(b => b.Id == id && !b.IsDeleted) .Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
.Include(b => b.LineItems.Where(li => !li.IsDeleted)) .Include(b => b.LineItems.Where(li => !li.IsDeleted))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount) public async Task<List<Bill>> GetForIndexAsync(int companyId, string? statusFilter, string? searchTerm, decimal? searchAmount)
{ {
var query = _context.Bills var query = _context.Bills
.Include(b => b.Vendor) .Include(b => b.Vendor)
.Include(b => b.LineItems.Where(li => !li.IsDeleted)) .Include(b => b.LineItems.Where(li => !li.IsDeleted))
.Where(b => !b.IsDeleted); .Where(b => b.CompanyId == companyId && !b.IsDeleted);
if (statusFilter == "Unpaid") if (statusFilter == "Unpaid")
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid); query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
@@ -69,32 +69,32 @@ public class BillRepository : Repository<Bill>, IBillRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<string?> GetLastBillNumberAsync(string prefix) public async Task<string?> GetLastBillNumberAsync(int companyId, string prefix)
{ {
return await _context.Bills return await _context.Bills
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(b => b.BillNumber.StartsWith(prefix)) .Where(b => b.CompanyId == companyId && b.BillNumber.StartsWith(prefix))
.OrderByDescending(b => b.BillNumber) .OrderByDescending(b => b.BillNumber)
.Select(b => b.BillNumber) .Select(b => b.BillNumber)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<string?> GetLastPaymentNumberAsync(string prefix) public async Task<string?> GetLastPaymentNumberAsync(int companyId, string prefix)
{ {
return await _context.BillPayments return await _context.BillPayments
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(p => p.PaymentNumber.StartsWith(prefix)) .Where(p => p.CompanyId == companyId && p.PaymentNumber.StartsWith(prefix))
.OrderByDescending(p => p.PaymentNumber) .OrderByDescending(p => p.PaymentNumber)
.Select(p => p.PaymentNumber) .Select(p => p.PaymentNumber)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end) public async Task<List<Bill>> GetForDateRangeAsync(int companyId, DateTime start, DateTime end)
{ {
return await _context.Bills return await _context.Bills
.Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end) .Where(b => b.CompanyId == companyId && !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
.Include(b => b.Vendor) .Include(b => b.Vendor)
.Include(b => b.LineItems.Where(li => !li.IsDeleted)) .Include(b => b.LineItems.Where(li => !li.IsDeleted))
.ThenInclude(li => li.Account) .ThenInclude(li => li.Account)
@@ -14,10 +14,10 @@ public class CustomerRepository : Repository<Customer>, ICustomerRepository
public CustomerRepository(ApplicationDbContext context) : base(context) { } public CustomerRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Customer?> LoadForDetailsAsync(int id) public async Task<Customer?> LoadForDetailsAsync(int id, int companyId)
{ {
return await _context.Customers return await _context.Customers
.Where(c => c.Id == id && !c.IsDeleted) .Where(c => c.Id == id && c.CompanyId == companyId && !c.IsDeleted)
.Include(c => c.PricingTier) .Include(c => c.PricingTier)
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted)) .Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
@@ -0,0 +1,83 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Infrastructure.Repositories;
/// <summary>
/// Typed repository for <see cref="InAppNotification"/>. Overrides the three hot-path
/// read operations so ORDER BY / SKIP / TAKE run in SQL rather than in C# after a full
/// table load — critical for the bell dropdown (Recent/Unread) which fires on every page.
/// </summary>
public class InAppNotificationRepository
: Repository<InAppNotification>, IInAppNotificationRepository
{
public InAppNotificationRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/>
public async Task<(List<InAppNotification> Items, int TotalCount)> GetPagedAsync(
bool isPlatformAdmin, int pageNumber, int pageSize)
{
// SuperAdmin path: bypass global filters, scope to CompanyId 0 (platform-only).
// Regular path: global filter already scopes to the current tenant.
var query = isPlatformAdmin
? _context.Set<InAppNotification>()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0)
: _context.Set<InAppNotification>()
.Where(n => true);
var totalCount = await query.CountAsync();
var items = await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, totalCount);
}
/// <inheritdoc/>
public async Task<List<InAppNotification>> GetRecentAsync(
bool isPlatformAdmin, int take = 20)
{
var query = isPlatformAdmin
? _context.Set<InAppNotification>()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0)
: _context.Set<InAppNotification>()
.Where(n => true);
return await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Take(take)
.ToListAsync();
}
/// <inheritdoc/>
public async Task<(List<InAppNotification> Items, int UnreadCount)> GetUnreadAsync(
bool isPlatformAdmin, int take = 20)
{
// Count ALL unread for the badge, then cap the items list for the dropdown.
var baseQuery = isPlatformAdmin
? _context.Set<InAppNotification>()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
: _context.Set<InAppNotification>()
.Where(n => !n.IsRead);
var unreadCount = await baseQuery.CountAsync();
var items = await baseQuery
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Take(take)
.ToListAsync();
return (items, unreadCount);
}
}
@@ -15,10 +15,10 @@ public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
public InvoiceRepository(ApplicationDbContext context) : base(context) { } public InvoiceRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Invoice?> LoadForViewAsync(int id) public async Task<Invoice?> LoadForViewAsync(int id, int companyId)
{ {
return await _context.Set<Invoice>() return await _context.Set<Invoice>()
.Where(i => i.Id == id && !i.IsDeleted) .Where(i => i.Id == id && i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer) .Include(i => i.Customer)
.Include(i => i.Job) .Include(i => i.Job)
.Include(i => i.PreparedBy) .Include(i => i.PreparedBy)
@@ -32,13 +32,13 @@ public class JobRepository : Repository<Job>, IJobRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Job?> LoadForDetailsAsync(int id) public async Task<Job?> LoadForDetailsAsync(int id, int companyId)
{ {
// Single query replaces the per-item N+1 loop that was in JobsController.Details. // Single query replaces the per-item N+1 loop that was in JobsController.Details.
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically // EF Core splits the multi-level ThenIncludes across two SQL queries automatically
// (split query behavior), keeping result set size manageable. // (split query behavior), keeping result set size manageable.
return await _context.Jobs return await _context.Jobs
.Where(j => j.Id == id && !j.IsDeleted) .Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer) .Include(j => j.Customer)
.Include(j => j.JobStatus) .Include(j => j.JobStatus)
.Include(j => j.JobPriority) .Include(j => j.JobPriority)
@@ -62,10 +62,10 @@ public class JobRepository : Repository<Job>, IJobRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Job?> LoadForEditAsync(int id) public async Task<Job?> LoadForEditAsync(int id, int companyId)
{ {
return await _context.Jobs return await _context.Jobs
.Where(j => j.Id == id && !j.IsDeleted) .Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.Customer) .Include(j => j.Customer)
.Include(j => j.JobStatus) .Include(j => j.JobStatus)
.Include(j => j.JobPriority) .Include(j => j.JobPriority)
@@ -86,18 +86,18 @@ public class JobRepository : Repository<Job>, IJobRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Job?> LoadForStatusChangeAsync(int id) public async Task<Job?> LoadForStatusChangeAsync(int id, int companyId)
{ {
return await _context.Jobs return await _context.Jobs
.Include(j => j.JobStatus) .Include(j => j.JobStatus)
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted); .FirstOrDefaultAsync(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted);
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId) public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId, int companyId)
{ {
return await _context.JobChangeHistories return await _context.JobChangeHistories
.Where(h => h.JobId == jobId && !h.IsDeleted) .Where(h => h.JobId == jobId && h.CompanyId == companyId && !h.IsDeleted)
.Include(h => h.ChangedBy) .Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt) .OrderByDescending(h => h.ChangedAt)
.AsNoTracking() .AsNoTracking()
@@ -176,10 +176,10 @@ public class JobRepository : Repository<Job>, IJobRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId) public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId, int companyId)
{ {
return await _context.Jobs return await _context.Jobs
.Where(j => j.Id == jobId && !j.IsDeleted) .Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted)
.Include(j => j.JobItems.Where(i => !i.IsDeleted)) .Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted)) .ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.JobItems.Where(i => !i.IsDeleted)) .Include(j => j.JobItems.Where(i => !i.IsDeleted))
@@ -188,11 +188,11 @@ public class JobRepository : Repository<Job>, IJobRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<int> GetReworkJobCountAsync(int originalJobId) public async Task<int> GetReworkJobCountAsync(int originalJobId, int companyId)
{ {
return await _context.Jobs return await _context.Jobs
.IgnoreQueryFilters() .IgnoreQueryFilters()
.CountAsync(j => j.OriginalJobId == originalJobId); .CountAsync(j => j.OriginalJobId == originalJobId && j.CompanyId == companyId);
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -15,50 +15,50 @@ public class NotificationLogRepository : Repository<NotificationLog>, INotificat
public NotificationLogRepository(ApplicationDbContext context) : base(context) { } public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId) => public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId, int companyId) =>
await _context.Set<NotificationLog>() await _context.Set<NotificationLog>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(n => n.InvoiceId == invoiceId) .Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
.OrderByDescending(n => n.SentAt) .OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId) => public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId, int companyId) =>
await _context.Set<NotificationLog>() await _context.Set<NotificationLog>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(n => n.InvoiceId == invoiceId) .Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
.OrderByDescending(n => n.SentAt) .OrderByDescending(n => n.SentAt)
.ToListAsync(); .ToListAsync();
/// <inheritdoc/> /// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId) => public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId, int companyId) =>
await _context.Set<NotificationLog>() await _context.Set<NotificationLog>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(n => n.QuoteId == quoteId) .Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
.OrderByDescending(n => n.SentAt) .OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId) => public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId, int companyId) =>
await _context.Set<NotificationLog>() await _context.Set<NotificationLog>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(n => n.QuoteId == quoteId) .Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
.OrderByDescending(n => n.SentAt) .OrderByDescending(n => n.SentAt)
.ToListAsync(); .ToListAsync();
/// <inheritdoc/> /// <inheritdoc/>
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId) => public async Task<NotificationLog?> GetLatestForJobAsync(int jobId, int companyId) =>
await _context.Set<NotificationLog>() await _context.Set<NotificationLog>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(n => n.JobId == jobId) .Where(n => n.JobId == jobId && n.CompanyId == companyId)
.OrderByDescending(n => n.SentAt) .OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId) => public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId, int companyId) =>
await _context.Set<NotificationLog>() await _context.Set<NotificationLog>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(n => n.JobId == jobId) .Where(n => n.JobId == jobId && n.CompanyId == companyId)
.OrderByDescending(n => n.SentAt) .OrderByDescending(n => n.SentAt)
.ToListAsync(); .ToListAsync();
@@ -26,10 +26,10 @@ public class PlainRepository<T> : IPlainRepository<T> where T : class
=> await _dbSet.FindAsync(id); => await _dbSet.FindAsync(id);
public virtual async Task<IEnumerable<T>> GetAllAsync() public virtual async Task<IEnumerable<T>> GetAllAsync()
=> await _dbSet.ToListAsync(); => await _dbSet.AsNoTracking().ToListAsync();
public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) public virtual async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.Where(predicate).ToListAsync(); => await _dbSet.AsNoTracking().Where(predicate).ToListAsync();
public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate) public virtual async Task<T?> FirstOrDefaultAsync(Expression<Func<T, bool>> predicate)
=> await _dbSet.FirstOrDefaultAsync(predicate); => await _dbSet.FirstOrDefaultAsync(predicate);
@@ -15,10 +15,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
public QuoteRepository(ApplicationDbContext context) : base(context) { } public QuoteRepository(ApplicationDbContext context) : base(context) { }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<Quote?> LoadForDetailsAsync(int id) public async Task<Quote?> LoadForDetailsAsync(int id, int companyId)
{ {
var quote = await _context.Quotes var quote = await _context.Quotes
.Where(q => q.Id == id && !q.IsDeleted) .Where(q => q.Id == id && q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.Customer) .Include(q => q.Customer)
.Include(q => q.PreparedBy) .Include(q => q.PreparedBy)
.Include(q => q.QuoteStatus) .Include(q => q.QuoteStatus)
@@ -32,7 +32,7 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
// QuoteItems with nested coats and prep services loaded separately to avoid // QuoteItems with nested coats and prep services loaded separately to avoid
// cartesian explosion from multiple collection includes in a single query. // cartesian explosion from multiple collection includes in a single query.
quote.QuoteItems = await _context.QuoteItems quote.QuoteItems = await _context.QuoteItems
.Where(qi => qi.QuoteId == id && !qi.IsDeleted) .Where(qi => qi.QuoteId == id && qi.CompanyId == companyId && !qi.IsDeleted)
.Include(qi => qi.Coats) .Include(qi => qi.Coats)
.ThenInclude(c => c.InventoryItem) .ThenInclude(c => c.InventoryItem)
.Include(qi => qi.Coats) .Include(qi => qi.Coats)
@@ -58,10 +58,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId) public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId, int companyId)
{ {
return await _context.QuoteChangeHistories return await _context.QuoteChangeHistories
.Where(h => h.QuoteId == quoteId && !h.IsDeleted) .Where(h => h.QuoteId == quoteId && h.CompanyId == companyId && !h.IsDeleted)
.Include(h => h.ChangedBy) .Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt) .OrderByDescending(h => h.ChangedAt)
.AsNoTracking() .AsNoTracking()
@@ -83,10 +83,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
} }
/// <inheritdoc/> /// <inheritdoc/>
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId) public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId, int companyId)
{ {
return await _context.QuoteItems return await _context.QuoteItems
.Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted) .Where(qi => qi.QuoteId == quoteId && qi.CompanyId == companyId && !qi.IsDeleted)
.Include(qi => qi.Coats) .Include(qi => qi.Coats)
.ThenInclude(c => c.InventoryItem) .ThenInclude(c => c.InventoryItem)
.Include(qi => qi.Coats) .Include(qi => qi.Coats)
@@ -70,6 +70,8 @@ public class UnitOfWork : IUnitOfWork
private IJobPhotoRepository? _jobPhotos; private IJobPhotoRepository? _jobPhotos;
private IRepository<JobNote>? _jobNotes; private IRepository<JobNote>? _jobNotes;
private IRepository<CustomerNote>? _customerNotes; private IRepository<CustomerNote>? _customerNotes;
private IRepository<CustomerContact>? _customerContacts;
private IRepository<CustomerPreferredPowder>? _customerPreferredPowders;
private IRepository<JobStatusHistory>? _jobStatusHistory; private IRepository<JobStatusHistory>? _jobStatusHistory;
private IRepository<PricingTier>? _pricingTiers; private IRepository<PricingTier>? _pricingTiers;
@@ -107,7 +109,7 @@ public class UnitOfWork : IUnitOfWork
private IPlainRepository<Announcement>? _announcements; private IPlainRepository<Announcement>? _announcements;
private IPlainRepository<BannedIp>? _bannedIps; private IPlainRepository<BannedIp>? _bannedIps;
private IPlainRepository<DashboardTip>? _dashboardTips; private IPlainRepository<DashboardTip>? _dashboardTips;
private IRepository<InAppNotification>? _inAppNotifications; private IInAppNotificationRepository? _inAppNotifications;
private IPlainRepository<ReleaseNote>? _releaseNotes; private IPlainRepository<ReleaseNote>? _releaseNotes;
// Bug Reports // Bug Reports
@@ -321,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 =>
@@ -432,8 +439,8 @@ public class UnitOfWork : IUnitOfWork
_dashboardTips ??= new PlainRepository<DashboardTip>(_context); _dashboardTips ??= new PlainRepository<DashboardTip>(_context);
/// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary> /// <summary>Repository for <see cref="InAppNotification"/> bell-notification records; tenant-filtered with soft delete.</summary>
public IRepository<InAppNotification> InAppNotifications => public IInAppNotificationRepository InAppNotifications =>
_inAppNotifications ??= new Repository<InAppNotification>(_context); _inAppNotifications ??= new InAppNotificationRepository(_context);
/// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary> /// <summary>Repository for <see cref="ReleaseNote"/> platform changelog entries; no tenant filter, no soft delete.</summary>
public IPlainRepository<ReleaseNote> ReleaseNotes => public IPlainRepository<ReleaseNote> ReleaseNotes =>
@@ -605,6 +605,12 @@ 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
@@ -1284,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
@@ -1557,6 +1564,7 @@ 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(),
ProjectName = record.ProjectName?.Trim(),
Description = record.Description?.Trim() Description = record.Description?.Trim()
?? record.SpecialInstructions?.Trim() ?? record.SpecialInstructions?.Trim()
?? "Imported job", ?? "Imported job",
@@ -3146,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++;
} }
@@ -3170,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();
@@ -47,6 +47,8 @@ public class EmailService : IEmailService
if (!_hostEnvironment.IsProduction()) if (!_hostEnvironment.IsProduction())
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}"; subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com"; var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating"; var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
@@ -104,6 +106,8 @@ public class EmailService : IEmailService
if (!_hostEnvironment.IsProduction()) if (!_hostEnvironment.IsProduction())
subject = $"[{_hostEnvironment.EnvironmentName}] {subject}"; subject = $"[{_hostEnvironment.EnvironmentName}] {subject}";
(toEmail, toName) = RedirectIfNonProd(toEmail, toName);
var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com"; var fromEmail = _configuration["SendGrid:FromEmail"] ?? "noreply@example.com";
var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating"; var fromName = _configuration["SendGrid:FromName"] ?? "Powder Coating";
@@ -138,6 +142,20 @@ public class EmailService : IEmailService
} }
} }
/// <summary>
/// In non-production environments, redirects outbound email to <c>SendGrid:DevRedirectEmail</c>
/// so real customers are never contacted outside of production. Double-gated on environment
/// name AND the config value so a misconfigured prod deploy can't accidentally redirect.
/// </summary>
private (string email, string name) RedirectIfNonProd(string toEmail, string toName)
{
if (_hostEnvironment.IsProduction()) return (toEmail, toName);
var devEmail = _configuration["SendGrid:DevRedirectEmail"];
if (string.IsNullOrWhiteSpace(devEmail)) return (toEmail, toName);
_logger.LogWarning("Non-production environment: redirecting email from {Original} to dev address {Dev}", toEmail, devEmail);
return (devEmail, $"[DEV → {toName} <{toEmail}>]");
}
/// <summary> /// <summary>
/// Sends the built SendGrid message and interprets the HTTP response. Extracted so both /// Sends the built SendGrid message and interprets the HTTP response. Extracted so both
/// send methods share identical dispatch and logging logic. /// send methods share identical dispatch and logging logic.
@@ -755,17 +755,20 @@ public class FinancialReportService : IFinancialReportService
var asOfEnd = asOf.AddDays(1).AddTicks(-1); var asOfEnd = asOf.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync(companyId); var companyName = await GetCompanyNameAsync(companyId);
var openBills = await _context.Bills // BalanceDue is a computed property — filter on persisted columns in SQL,
// then apply BalanceDue > 0 client-side after materialisation.
var openBills = (await _context.Bills
.Include(b => b.Vendor) .Include(b => b.Vendor)
.Where(b => b.CompanyId == companyId .Where(b => b.CompanyId == companyId
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Draft
&& b.Status != BillStatus.Voided && b.Status != BillStatus.Voided
&& b.Status != BillStatus.Paid && b.Status != BillStatus.Paid
&& b.BillDate <= asOfEnd && b.BillDate <= asOfEnd)
&& b.BalanceDue > 0)
.OrderBy(b => b.Vendor!.CompanyName) .OrderBy(b => b.Vendor!.CompanyName)
.ThenBy(b => b.DueDate) .ThenBy(b => b.DueDate)
.ToListAsync(); .ToListAsync())
.Where(b => b.BalanceDue > 0)
.ToList();
static string AgingBucket(int d) => d switch static string AgingBucket(int d) => d switch
{ {
@@ -94,7 +94,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values, quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}"); $"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error) = await _emailService.SendEmailAsync(
@@ -137,7 +137,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteSent, values, quote.CompanyId, NotificationType.QuoteSent, values,
$"Your Quote {quote.QuoteNumber} from {companyName}"); $"Your Quote {quote.QuoteNumber} from {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -300,7 +300,7 @@ public class NotificationService : INotificationService
quote.CompanyId, NotificationType.QuoteApproved, values, quote.CompanyId, NotificationType.QuoteApproved, values,
$"Quote {quote.QuoteNumber} Approved — {companyName}"); $"Quote {quote.QuoteNumber} Approved — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -383,7 +383,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync( var (subject, htmlBody) = await GetRenderedEmailAsync(
job.CompanyId, notifType, values, defaultSubject); job.CompanyId, notifType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -451,7 +451,7 @@ public class NotificationService : INotificationService
job.CompanyId, NotificationType.JobCompleted, values, job.CompanyId, NotificationType.JobCompleted, values,
$"Job {job.JobNumber} Complete — {companyName}"); $"Job {job.JobNumber} Complete — {companyName}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -674,7 +674,7 @@ public class NotificationService : INotificationService
"""; """;
} }
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = !string.IsNullOrEmpty(paymentUrl) var plainText = !string.IsNullOrEmpty(paymentUrl)
? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}" ? StripHtml(htmlBody) + $"\r\n\r\nPay online: {paymentUrl}"
: StripHtml(fullHtml); : StripHtml(fullHtml);
@@ -793,7 +793,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReceived, values, invoice.CompanyId, NotificationType.PaymentReceived, values,
$"Payment Received — Invoice {invoice.InvoiceNumber}"); $"Payment Received — Invoice {invoice.InvoiceNumber}");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -867,7 +867,7 @@ public class NotificationService : INotificationService
invoice.CompanyId, NotificationType.PaymentReminder, values, invoice.CompanyId, NotificationType.PaymentReminder, values,
$"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)"); $"Payment Reminder — Invoice {invoice.InvoiceNumber} ({daysOverdue} days overdue)");
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, customer.UnsubscribeToken, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error, recipientsLog) = await SendToEmailListAsync( var (success, error, recipientsLog) = await SendToEmailListAsync(
@@ -971,7 +971,7 @@ public class NotificationService : INotificationService
var (subject, htmlBody) = await GetRenderedEmailAsync( var (subject, htmlBody) = await GetRenderedEmailAsync(
quote.CompanyId, notificationType, values, defaultSubject); quote.CompanyId, notificationType, values, defaultSubject);
var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync()); var fullHtml = AppendUnsubscribeFooterHtml(htmlBody, token: null, company, await GetBaseUrlAsync(), replyToEmail);
var plainText = StripHtml(fullHtml); var plainText = StripHtml(fullHtml);
var (success, error) = await _emailService.SendEmailAsync( var (success, error) = await _emailService.SendEmailAsync(
@@ -1218,7 +1218,7 @@ public class NotificationService : INotificationService
var (custSubject, custHtml) = await GetRenderedEmailAsync( var (custSubject, custHtml) = await GetRenderedEmailAsync(
appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject); appointment.CompanyId, NotificationType.AppointmentReminder, customerValues, defaultSubject);
var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl); var custFullHtml = AppendUnsubscribeFooterHtml(custHtml, customer.UnsubscribeToken, company, baseUrl, replyToEmail);
var custPlainText = StripHtml(custFullHtml); var custPlainText = StripHtml(custFullHtml);
var (custOk, custErr, custLog) = await SendToEmailListAsync( var (custOk, custErr, custLog) = await SendToEmailListAsync(
@@ -1388,17 +1388,25 @@ public class NotificationService : INotificationService
/// <summary> /// <summary>
/// Appends CAN-SPAM required footer as HTML. /// Appends CAN-SPAM required footer as HTML.
/// </summary> /// </summary>
private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null) private static string AppendUnsubscribeFooterHtml(string htmlBody, string? token, Company? company = null, string? baseUrl = null, string? replyToEmail = null)
{ {
var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl); var hasUnsubscribeUrl = !string.IsNullOrEmpty(token) && !string.IsNullOrEmpty(baseUrl);
var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address); var hasAddress = company != null && !string.IsNullOrWhiteSpace(company.Address);
var hasReplyTo = !string.IsNullOrWhiteSpace(replyToEmail);
if (!hasUnsubscribeUrl && !hasAddress) if (!hasUnsubscribeUrl && !hasAddress && !hasReplyTo)
return htmlBody; return htmlBody;
var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" + var footer = "<hr style=\"border: none; border-top: 1px solid #eee; margin: 24px 0;\">" +
"<p style=\"font-size: 0.8em; color: #888; margin: 0;\">"; "<p style=\"font-size: 0.8em; color: #888; margin: 0;\">";
if (hasReplyTo)
{
var encodedEmail = WebUtility.HtmlEncode(replyToEmail!);
footer += $"Questions? Reply to this email or contact us at <a href=\"mailto:{encodedEmail}\" style=\"color: #888;\">{encodedEmail}</a>";
if (hasAddress || hasUnsubscribeUrl) footer += "<br>";
}
if (hasAddress) if (hasAddress)
{ {
var addressLine = BuildAddressLine(company!); var addressLine = BuildAddressLine(company!);
@@ -1535,7 +1543,15 @@ public class NotificationService : INotificationService
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted); .FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
return (prefs?.EmailFromAddress, prefs?.EmailFromName); var email = prefs?.EmailFromAddress;
var name = prefs?.EmailFromName;
if (string.IsNullOrWhiteSpace(email))
_logger.LogWarning("No Reply-To email configured for company {CompanyId} — outgoing emails will show platform sender as reply address", companyId);
else
_logger.LogDebug("Reply-To for company {CompanyId}: {ReplyToEmail}", companyId, email);
return (email, name);
} }
/// <summary> /// <summary>
@@ -44,8 +44,11 @@ public partial class SeedDataService
var accounts = new List<Account> var accounts = new List<Account>
{ {
// ── ASSETS ──────────────────────────────────────────────────────── // ── ASSETS ────────────────────────────────────────────────────────
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", CompanyId = company.Id, CreatedAt = now }, // Opening balances represent accumulated cash before the 12-month seeded history window.
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", CompanyId = company.Id, CreatedAt = now }, // Without them, 12 months of seeded expenses outpace ~3 months of seeded revenue and
// the checking account shows a large negative — unrealistic for a demo.
new Account { AccountNumber = "1000", Name = "Checking Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Checking, IsSystem = true, IsActive = true, Description = "Primary business checking account", OpeningBalance = 75_000m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 75_000m, CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1010", Name = "Savings Account", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Savings, IsSystem = false, IsActive = true, Description = "Business savings account", OpeningBalance = 14_500m, OpeningBalanceDate = now.AddYears(-1), CurrentBalance = 14_500m, CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1100", Name = "Accounts Receivable", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsSystem = true, IsActive = true, Description = "Amounts owed by customers for services", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "1100", Name = "Accounts Receivable", AccountType = AccountType.Asset, AccountSubType = AccountSubType.AccountsReceivable, IsSystem = true, IsActive = true, Description = "Amounts owed by customers for services", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Powder coating materials in stock", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "1200", Name = "Inventory - Powder", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Powder coating materials in stock", CompanyId = company.Id, CreatedAt = now },
new Account { AccountNumber = "1210", Name = "Inventory - Consumables", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Masking, tape, and other consumables", CompanyId = company.Id, CreatedAt = now }, new Account { AccountNumber = "1210", Name = "Inventory - Consumables", AccountType = AccountType.Asset, AccountSubType = AccountSubType.Inventory, IsSystem = false, IsActive = true, Description = "Masking, tape, and other consumables", CompanyId = company.Id, CreatedAt = now },
@@ -0,0 +1,140 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 8 <see cref="AiItemPrediction"/> demo records and attaches them to the first
/// 8 eligible <see cref="QuoteItem"/> records, marking those items as AI-analysed.
/// </summary>
/// <remarks>
/// <para>
/// "Eligible" means <c>SurfaceAreaSqFt &gt; 0</c>, not a labor item, and not already
/// linked to a prediction. This ensures the seeder is safe to run even if a partial seed
/// left some items pre-linked.
/// </para>
/// <para>
/// Each prediction record captures a realistic AI analysis: predicted surface area,
/// estimated minutes, complexity tier, unit price, confidence level, reasoning text, and
/// comma-separated AI tags. Three of the eight items have <c>UserOverrodeEstimate = true</c>
/// to demonstrate the override-tracking feature on the AI Accuracy report.
/// </para>
/// <para>
/// Items are updated in-place with <c>IsAiItem = true</c> and the FK
/// <c>AiPredictionId</c> pointing to the new prediction. <c>SaveChangesAsync</c> is called
/// per item so any single FK conflict (unlikely in a fresh seed) does not abort the others.
/// </para>
/// Idempotency: returns 0 immediately if any AiItemPrediction records already exist for
/// the company.
/// </remarks>
/// <param name="company">The tenant company to seed predictions for.</param>
/// <returns>Number of prediction records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedAiPredictionsAsync(Company company)
{
var existingCount = await _context.Set<AiItemPrediction>()
.IgnoreQueryFilters()
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
if (existingCount > 0)
return 0;
// Grab the first 8 eligible quote items ordered by id for determinism
var quoteItems = await _context.Set<QuoteItem>()
.IgnoreQueryFilters()
.Include(qi => qi.Quote)
.Where(qi => qi.CompanyId == company.Id
&& !qi.IsDeleted
&& qi.SurfaceAreaSqFt > 0
&& !qi.IsLaborItem
&& qi.AiPredictionId == null)
.OrderBy(qi => qi.Id)
.Take(8)
.ToListAsync();
if (quoteItems.Count == 0)
return 0;
// Per-slot prediction specs — deterministic, varied across complexity/confidence tiers.
// PredictedSqFt is intentionally close but NOT identical to the actual item SqFt so the
// AI Accuracy report shows realistic prediction deltas.
var specs = new[]
{
// slot 0 — complex automotive, AI nailed it
( sqft: 13.5m, mins: 88, complexity: "Complex", confidence: "High",
price: 125.00m, tags: "automotive,tubular,custom", rounds: 1, overrode: false,
reasoning: "Detected a tubular motorcycle frame with multiple weld joints. High complexity due to intricate geometry and masking requirements around bearing surfaces. Confidence high — similar frames appear frequently in training data." ),
// slot 1 — wheel set, quick read, accepted as-is
( sqft: 11.2m, mins: 42, complexity: "Simple", confidence: "High",
price: 98.00m, tags: "automotive,wheels,aluminum", rounds: 1, overrode: false,
reasoning: "Four aluminum wheels, uniform shape, minimal masking needed. Straightforward batch candidate for the main oven. Estimated surface area based on standard 18\" wheel profile." ),
// slot 2 — bumper job, user bumped sqft slightly
( sqft: 14.8m, mins: 62, complexity: "Moderate", confidence: "Medium",
price: 135.00m, tags: "automotive,bumper,off-road", rounds: 2, overrode: true,
reasoning: "Steel off-road bumper and rock sliders. Moderate complexity — flat stock with mounting tabs. Second image round requested for accurate rock slider dimensions. User adjusted surface area slightly after physical measurement." ),
// slot 3 — large gate, low confidence, user corrected price
( sqft: 34.2m, mins: 195, complexity: "Complex", confidence: "Low",
price: 310.00m, tags: "architectural,gate,ornamental", rounds: 2, overrode: true,
reasoning: "Wrought iron entry gate with decorative scrollwork. Low confidence due to depth ambiguity in photos — scrollwork surface area is difficult to estimate from images alone. Recommend physical measurement before finalising price. User overrode unit price after measuring on-site." ),
// slot 4 — patio furniture, solid read
( sqft: 22.8m, mins: 52, complexity: "Moderate", confidence: "High",
price: 195.00m, tags: "furniture,outdoor,patio", rounds: 1, overrode: false,
reasoning: "Six-piece patio furniture set: four chairs, one table, one side table. Powder-coated tubular steel, standard outdoor finish. Good photo coverage — confidence high. Recommend Textured Beige or Satin Bronze for exterior durability." ),
// slot 5 — handrail, accepted price
( sqft: 39.0m, mins: 118, complexity: "Moderate", confidence: "High",
price: 342.00m, tags: "architectural,handrail,railing", rounds: 1, overrode: false,
reasoning: "40-foot steel handrail system, square tube construction. Consistent profile makes area calculation straightforward. Standard Gloss Black most common finish for this application — confirmed with customer." ),
// slot 6 — brake calipers, small & simple
( sqft: 3.8m, mins: 30, complexity: "Simple", confidence: "High",
price: 65.00m, tags: "automotive,brake,caliper", rounds: 1, overrode: false,
reasoning: "Set of four brake calipers, cast iron with machined mating surfaces. Masking required on piston bores and bleed nipples. Candy Red most requested finish. High confidence — calipers are a common item with well-established pricing." ),
// slot 7 — bicycle frame, two-round conversation
( sqft: 6.1m, mins: 65, complexity: "Moderate", confidence: "Medium",
price: 82.00m, tags: "recreational,bicycle,frame", rounds: 2, overrode: true,
reasoning: "Road bicycle frame, aluminium alloy. Second image round needed to assess cable routing channels and dropout geometry. Moderate complexity due to small-radius bends. User adjusted surface area after AI initially underestimated top-tube length." )
};
var seeded = 0;
for (int i = 0; i < quoteItems.Count && i < specs.Length; i++)
{
var item = quoteItems[i];
var s = specs[i];
var prediction = new AiItemPrediction
{
PredictedSurfaceAreaSqFt = s.sqft,
PredictedMinutes = s.mins,
PredictedComplexity = s.complexity,
PredictedUnitPrice = s.price,
Confidence = s.confidence,
Reasoning = s.reasoning,
AiTags = s.tags,
ConversationRounds = s.rounds,
UserOverrodeEstimate = s.overrode,
CompanyId = company.Id,
CreatedAt = item.CreatedAt
};
await _context.Set<AiItemPrediction>().AddAsync(prediction);
await _context.SaveChangesAsync();
seeded++;
// Mark the quote item as AI-analysed and link the prediction
item.IsAiItem = true;
item.AiPredictionId = prediction.Id;
item.AiTags = s.tags;
await _context.SaveChangesAsync();
}
return seeded;
}
}
@@ -337,15 +337,122 @@ public partial class SeedDataService
var startDate = DateTime.Today; var startDate = DateTime.Today;
var appointmentTitles = new Dictionary<string, string[]> var appointmentTitles = new Dictionary<string, string[]>
{ {
["DROP_OFF"] = new[] { "Customer Drop-Off", "Parts Delivery", "Item Drop-Off", "Material Drop-Off" }, ["DROP_OFF"] = new[] { "Customer Drop-Off", "Parts Delivery", "Item Drop-Off", "Material Drop-Off" },
["PICK_UP"] = new[] { "Customer Pick-Up", "Collection Appointment", "Order Pick-Up", "Completed Items Pick-Up" }, ["PICK_UP"] = new[] { "Customer Pick-Up", "Collection Appointment", "Order Pick-Up", "Completed Items Pick-Up" },
["CONSULTATION"] = new[] { "Quote Discussion", "Project Consultation", "Initial Consultation", "Color Selection Meeting" }, ["CONSULTATION"] = new[] { "Quote Discussion", "Project Consultation", "Initial Consultation", "Color Selection Meeting" },
["JOB_WORK"] = new[] { "Sandblasting Session", "Coating Work", "Quality Inspection", "Final Finishing" } ["JOB_WORK"] = new[] { "Sandblasting Session", "Coating Work", "Quality Inspection", "Final Finishing" }
}; };
// Get status IDs by code for easy assignment // Get status IDs by code for easy assignment
var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id; var scheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "SCHEDULED").Id;
var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id; var confirmedStatusId = appointmentStatuses.First(s => s.StatusCode == "CONFIRMED").Id;
var completedStatusId = appointmentStatuses.First(s => s.StatusCode == "COMPLETED").Id;
var cancelledStatusId = appointmentStatuses.First(s => s.StatusCode == "CANCELLED").Id;
var noShowStatusId = appointmentStatuses.First(s => s.StatusCode == "NO_SHOW").Id;
var rescheduledStatusId = appointmentStatuses.First(s => s.StatusCode == "RESCHEDULED").Id;
// ── PAST APPOINTMENTS (last 90 days) — Completed, Cancelled, No Show ──────
// Walks backward through weekdays; ~40% chance of an appointment per day
// for ~25 records spread naturally across the history window.
var pastRandom = new Random(77); // separate seed keeps past/future independent
var pastAppointmentSeq = 1;
static string? CancelNote(Random r)
{
var reasons = new[]
{
"Customer cancelled — rescheduling for next week.",
"Customer cancelled — no reason given.",
"Shop closed for equipment maintenance.",
"Customer called to reschedule.",
"Customer unavailable — will call back.",
"Cancelled by shop — scheduling conflict."
};
return reasons[r.Next(reasons.Length)];
}
for (int daysBack = 1; daysBack <= 90 && pastAppointmentSeq <= 25; daysBack++)
{
var pastDate = DateTime.Today.AddDays(-daysBack);
if (pastDate.DayOfWeek == DayOfWeek.Saturday || pastDate.DayOfWeek == DayOfWeek.Sunday)
continue;
if (pastRandom.Next(100) >= 40) // ~40% chance = ~26 weekday hits over 90 days
continue;
var aptType = appointmentTypes[pastRandom.Next(appointmentTypes.Count)];
var customer = customers[pastRandom.Next(customers.Count)];
int startHour = pastRandom.Next(8, 17);
int startMinute = pastRandom.Next(0, 4) * 15;
var aptStart = new DateTime(pastDate.Year, pastDate.Month, pastDate.Day, startHour, startMinute, 0, DateTimeKind.Utc);
int duration = pastRandom.Next(1, 5) * 30;
var aptEnd = aptStart.AddMinutes(duration);
// 60% Completed, 25% Cancelled, 10% No Show, 5% Rescheduled
int roll = pastRandom.Next(100);
int pastStatusId;
DateTime? actualStart = null, actualEnd = null;
string? pastNotes = null;
if (roll < 60)
{
pastStatusId = completedStatusId;
actualStart = aptStart.AddMinutes(pastRandom.Next(-5, 11)); // ±510 min variance
actualEnd = aptEnd.AddMinutes(pastRandom.Next(-10, 16));
}
else if (roll < 85)
{
pastStatusId = cancelledStatusId;
pastNotes = CancelNote(pastRandom);
}
else if (roll < 95)
{
pastStatusId = noShowStatusId;
pastNotes = "Customer did not arrive. Follow-up call left.";
}
else
{
pastStatusId = rescheduledStatusId;
pastNotes = "Rescheduled at customer request — see follow-up appointment.";
}
// Optional job link (35% chance for past JOB_WORK; 15% for others)
int? pastJobId = null;
if (jobs.Any())
{
int linkChance = aptType.TypeCode == "JOB_WORK" ? 35 : 15;
if (pastRandom.Next(100) < linkChance)
pastJobId = jobs[pastRandom.Next(jobs.Count)].Id;
}
string? assignedId = null;
if (workers.Any() && pastRandom.Next(100) < 60)
assignedId = workers[pastRandom.Next(workers.Count)].Id;
var pastLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
var pastTitle = $"{pastLabel} — {appointmentTitles[aptType.TypeCode][pastRandom.Next(appointmentTitles[aptType.TypeCode].Length)]}";
appointments.Add(new Appointment
{
AppointmentNumber = $"APT-{pastDate:yyMM}-{pastAppointmentSeq++:D4}",
CustomerId = customer.Id,
JobId = pastJobId,
AppointmentStatusId = pastStatusId,
AppointmentTypeId = aptType.Id,
AssignedUserId = assignedId,
Title = pastTitle,
ScheduledStartTime = aptStart,
ScheduledEndTime = aptEnd,
ActualStartTime = actualStart,
ActualEndTime = actualEnd,
IsAllDay = false,
IsReminderEnabled = false, // reminders don't fire for past appointments
ReminderMinutesBefore = 30,
Notes = pastNotes,
CompanyId = company.Id,
CreatedAt = aptStart.AddDays(-pastRandom.Next(1, 8)) // booked 17 days ahead
});
}
// Generate 50 appointments across next 60 days (weekdays only) // Generate 50 appointments across next 60 days (weekdays only)
int appointmentsCreated = 0; int appointmentsCreated = 0;
@@ -388,7 +495,8 @@ public partial class SeedDataService
int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId; int statusId = random.Next(100) < 80 ? scheduledStatusId : confirmedStatusId;
// Title // Title
string title = $"{customer.CompanyName} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}"; string customerLabel = string.IsNullOrEmpty(customer.CompanyName) ? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim() : customer.CompanyName;
string title = $"{customerLabel} - {appointmentTitles[appointmentType.TypeCode][random.Next(appointmentTitles[appointmentType.TypeCode].Length)]}";
// Optional job link (40% chance if type is JOB_WORK, 20% for others) // Optional job link (40% chance if type is JOB_WORK, 20% for others)
int? jobId = null; int? jobId = null;
@@ -84,10 +84,11 @@ public partial class SeedDataService
.Where(v => v.CompanyId == company.Id && !v.IsDeleted) .Where(v => v.CompanyId == company.Id && !v.IsDeleted)
.ToListAsync(); .ToListAsync();
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.FirstOrDefault(); var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.FirstOrDefault();
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.FirstOrDefault(); var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.FirstOrDefault();
var aceHardware = vendors.FirstOrDefault(v => v.CompanyName.Contains("Ace")) ?? vendors.FirstOrDefault(); var grainger = vendors.FirstOrDefault(v => v.CompanyName.Contains("Grainger")) ?? vendors.FirstOrDefault();
var fastenal = vendors.FirstOrDefault(v => v.CompanyName.Contains("Fastenal")) ?? vendors.FirstOrDefault(); var harbor = vendors.FirstOrDefault(v => v.CompanyName.Contains("Harbor")) ?? vendors.FirstOrDefault();
var localSupply = vendors.FirstOrDefault(v => v.CompanyName.Contains("Local")) ?? vendors.FirstOrDefault();
var fallback = vendors.FirstOrDefault(); var fallback = vendors.FirstOrDefault();
if (fallback == null) if (fallback == null)
@@ -125,6 +126,105 @@ public partial class SeedDataService
return bill; return bill;
} }
// ── ELECTRIC UTILITY — months 12 through 4 (paid) ─────────────────
// Monthly bills going back 12 months fill the AP and expense trend charts.
// Amounts reflect seasonal variation (higher summer AC, higher winter heat).
var elecAmounts = new decimal[] { 640m, 620m, 595m, 580m, 570m, 558m, 545m, 562m, 574m };
for (int m = 12; m >= 4; m--)
{
var bd = now.AddDays(-(m * 30 + 2));
var dd = bd.AddDays(15);
var pd = dd.AddDays(2);
var amt = elecAmounts[12 - m];
await AddBill(new Bill
{
VendorInvoiceNumber = $"ELEC-HIST-{m:D2}",
VendorId = fallback.Id,
APAccountId = apAccount.Id,
BillDate = bd,
DueDate = dd,
Status = BillStatus.Paid,
Terms = "Due on Receipt",
Memo = $"Electric — {m} months ago",
SubTotal = amt,
Total = amt,
AmountPaid = amt,
CreatedAt = bd,
LineItems = { new BillLineItem { AccountId = utilitiesAccount?.Id, Description = "Commercial Electric — monthly usage", Quantity = 1, UnitPrice = amt, Amount = amt, DisplayOrder = 1 } }
}, new BillPayment
{
VendorId = fallback.Id,
BankAccountId = checkingAccount.Id,
PaymentDate = pd,
Amount = amt,
PaymentMethod = PaymentMethod.BankTransferACH,
Memo = $"Electric bill — auto pay (month -{m})"
});
}
// ── POWDER ORDERS — months 12, 9, 6 (quarterly history) ───────────
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-61041",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-365),
DueDate = now.AddDays(-335),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Annual powder restock — month 12",
SubTotal = 1_080.00m, Total = 1_080.00m, AmountPaid = 1_080.00m,
CreatedAt = now.AddDays(-365),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 50 lbs", Quantity = 2, UnitPrice = 178.00m, Amount = 356.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 50 lbs", Quantity = 2, UnitPrice = 160.00m, Amount = 320.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Masking Tape & Plugs Kit", Quantity = 2, UnitPrice = 152.00m, Amount = 304.00m, DisplayOrder = 3 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Straps Assortment", Quantity = 1, UnitPrice = 100.00m, Amount = 100.00m, DisplayOrder = 4 }
}
}, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-335), Amount = 1_080.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-61041 — paid in full" });
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-68820",
VendorId = (columbia ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-275),
DueDate = now.AddDays(-245),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Quarterly specialty colors — month 9",
SubTotal = 940.00m, Total = 940.00m, AmountPaid = 940.00m,
CreatedAt = now.AddDays(-275),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Candy Red Metallic — 10 lbs", Quantity = 3, UnitPrice = 145.00m, Amount = 435.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Safety Yellow Powder — 25 lbs", Quantity = 2, UnitPrice = 115.00m, Amount = 230.00m, DisplayOrder = 2 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Hanging Racks & J-Hooks", Quantity = 1, UnitPrice = 275.00m, Amount = 275.00m, DisplayOrder = 3 }
}
}, new BillPayment { VendorId = (columbia ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-245), Amount = 940.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-68820 — paid in full" });
await AddBill(new Bill
{
VendorInvoiceNumber = "PP-73110",
VendorId = (prismatic ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-185),
DueDate = now.AddDays(-155),
Status = BillStatus.Paid,
Terms = "Net 30",
Memo = "Quarterly powder restock — month 6",
SubTotal = 1_020.00m, Total = 1_020.00m, AmountPaid = 1_020.00m,
CreatedAt = now.AddDays(-185),
LineItems =
{
new BillLineItem { AccountId = powderAccount?.Id, Description = "Matte Black Powder — 25 lbs", Quantity = 4, UnitPrice = 89.00m, Amount = 356.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Gloss White Powder — 25 lbs", Quantity = 3, UnitPrice = 86.50m, Amount = 259.50m, DisplayOrder = 2 },
new BillLineItem { AccountId = powderAccount?.Id, Description = "Satin Bronze Powder — 25 lbs", Quantity = 2, UnitPrice = 138.00m, Amount = 276.00m, DisplayOrder = 3 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Wire Brushes & Abrasives",Quantity = 1, UnitPrice = 128.50m, Amount = 128.50m, DisplayOrder = 4 }
}
}, new BillPayment { VendorId = (prismatic ?? fallback).Id, BankAccountId = checkingAccount.Id, PaymentDate = now.AddDays(-155), Amount = 1_020.00m, PaymentMethod = PaymentMethod.BankTransferACH, Memo = "PP-73110 — paid in full" });
// ── POWDER ORDERS ───────────────────────────────────────────────────── // ── POWDER ORDERS ─────────────────────────────────────────────────────
// Month -3: Large powder restock — Paid // Month -3: Large powder restock — Paid
@@ -270,11 +370,11 @@ public partial class SeedDataService
// ── CONSUMABLES / HARDWARE ───────────────────────────────────────────── // ── CONSUMABLES / HARDWARE ─────────────────────────────────────────────
// Month -3: Fastenal hardware — Paid // Month -3: Harbor Freight consumables — Paid
await AddBill(new Bill await AddBill(new Bill
{ {
VendorInvoiceNumber = "FST-18822", VendorInvoiceNumber = "HBF-18822",
VendorId = (fastenal ?? fallback).Id, VendorId = (harbor ?? fallback).Id,
APAccountId = apAccount.Id, APAccountId = apAccount.Id,
BillDate = now.AddDays(-85), BillDate = now.AddDays(-85),
DueDate = now.AddDays(-55), DueDate = now.AddDays(-55),
@@ -293,20 +393,20 @@ public partial class SeedDataService
} }
}, new BillPayment }, new BillPayment
{ {
VendorId = (fastenal ?? fallback).Id, VendorId = (harbor ?? fallback).Id,
BankAccountId = checkingAccount.Id, BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-55), PaymentDate = now.AddDays(-55),
Amount = 412.50m, Amount = 412.50m,
PaymentMethod = PaymentMethod.Check, PaymentMethod = PaymentMethod.Check,
CheckNumber = "1082", CheckNumber = "1082",
Memo = "FST-18822 — paid in full" Memo = "HBF-18822 — paid in full"
}); });
// Month -1: Fastenal — Paid // Month -1: Harbor Freight — Paid
await AddBill(new Bill await AddBill(new Bill
{ {
VendorInvoiceNumber = "FST-20041", VendorInvoiceNumber = "HBF-20041",
VendorId = (fastenal ?? fallback).Id, VendorId = (harbor ?? fallback).Id,
APAccountId = apAccount.Id, APAccountId = apAccount.Id,
BillDate = now.AddDays(-40), BillDate = now.AddDays(-40),
DueDate = now.AddDays(-10), DueDate = now.AddDays(-10),
@@ -325,20 +425,20 @@ public partial class SeedDataService
} }
}, new BillPayment }, new BillPayment
{ {
VendorId = (fastenal ?? fallback).Id, VendorId = (harbor ?? fallback).Id,
BankAccountId = checkingAccount.Id, BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-10), PaymentDate = now.AddDays(-10),
Amount = 298.00m, Amount = 298.00m,
PaymentMethod = PaymentMethod.Check, PaymentMethod = PaymentMethod.Check,
CheckNumber = "1086", CheckNumber = "1086",
Memo = "FST-20041 — paid in full" Memo = "HBF-20041 — paid in full"
}); });
// Current: Fastenal — Open // Current: Harbor Freight — Open
await AddBill(new Bill await AddBill(new Bill
{ {
VendorInvoiceNumber = "FST-20441", VendorInvoiceNumber = "HBF-20441",
VendorId = (fastenal ?? fallback).Id, VendorId = (harbor ?? fallback).Id,
APAccountId = apAccount.Id, APAccountId = apAccount.Id,
BillDate = now.AddDays(-7), BillDate = now.AddDays(-7),
DueDate = now.AddDays(23), DueDate = now.AddDays(23),
@@ -361,8 +461,8 @@ public partial class SeedDataService
// Month -3: Sandblaster service — Paid // Month -3: Sandblaster service — Paid
await AddBill(new Bill await AddBill(new Bill
{ {
VendorInvoiceNumber = "ACE-6901", VendorInvoiceNumber = "GRG-6901",
VendorId = (aceHardware ?? fallback).Id, VendorId = (grainger ?? fallback).Id,
APAccountId = apAccount.Id, APAccountId = apAccount.Id,
BillDate = now.AddDays(-80), BillDate = now.AddDays(-80),
DueDate = now.AddDays(-50), DueDate = now.AddDays(-50),
@@ -380,20 +480,20 @@ public partial class SeedDataService
} }
}, new BillPayment }, new BillPayment
{ {
VendorId = (aceHardware ?? fallback).Id, VendorId = (grainger ?? fallback).Id,
BankAccountId = checkingAccount.Id, BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-50), PaymentDate = now.AddDays(-50),
Amount = 310.00m, Amount = 310.00m,
PaymentMethod = PaymentMethod.Check, PaymentMethod = PaymentMethod.Check,
CheckNumber = "1079", CheckNumber = "1079",
Memo = "ACE-6901 — sandblaster parts" Memo = "GRG-6901 — sandblaster parts"
}); });
// Month -1: Oven repair — Partially paid // Month -1: Oven repair — Partially paid
await AddBill(new Bill await AddBill(new Bill
{ {
VendorInvoiceNumber = "ACE-7714", VendorInvoiceNumber = "GRG-7714",
VendorId = (aceHardware ?? fallback).Id, VendorId = (grainger ?? fallback).Id,
APAccountId = apAccount.Id, APAccountId = apAccount.Id,
BillDate = now.AddDays(-20), BillDate = now.AddDays(-20),
DueDate = now.AddDays(10), DueDate = now.AddDays(10),
@@ -411,13 +511,13 @@ public partial class SeedDataService
} }
}, new BillPayment }, new BillPayment
{ {
VendorId = (aceHardware ?? fallback).Id, VendorId = (grainger ?? fallback).Id,
BankAccountId = checkingAccount.Id, BankAccountId = checkingAccount.Id,
PaymentDate = now.AddDays(-10), PaymentDate = now.AddDays(-10),
Amount = 200.00m, Amount = 200.00m,
PaymentMethod = PaymentMethod.Check, PaymentMethod = PaymentMethod.Check,
CheckNumber = "1087", CheckNumber = "1087",
Memo = "ACE-7714 — partial payment" Memo = "GRG-7714 — partial payment"
}); });
// ── UTILITIES (3 months each) ───────────────────────────────────────── // ── UTILITIES (3 months each) ─────────────────────────────────────────
@@ -509,6 +609,74 @@ public partial class SeedDataService
Memo = "Electric bill — auto pay" Memo = "Electric bill — auto pay"
}); });
// ── AP AGING DEMO BILLS ───────────────────────────────────────────────
// These open/unpaid bills populate all four AP aging buckets for report demos.
// 30-60 day bucket: consumables that slipped through AP (~40 days past due)
await AddBill(new Bill
{
VendorInvoiceNumber = "HBF-19900",
VendorId = (harbor ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-70),
DueDate = now.AddDays(-40),
Status = BillStatus.Open,
Terms = "Net 30",
Memo = "Shop consumables — unpaid (aging demo)",
SubTotal = 228.50m,
Total = 228.50m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-70),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Silicone Plugs Assortment", Quantity = 2, UnitPrice = 64.25m, Amount = 128.50m, DisplayOrder = 1 },
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Ground Strap Replacements", Quantity = 1, UnitPrice = 100.00m, Amount = 100.00m, DisplayOrder = 2 }
}
});
// 61-90 day bucket: blast media from Local Industrial Supply (~72 days past due)
await AddBill(new Bill
{
VendorInvoiceNumber = "LIS-3301",
VendorId = (localSupply ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-102),
DueDate = now.AddDays(-72),
Status = BillStatus.Open,
Terms = "Net 30",
Memo = "Aluminum oxide blast media — unpaid (aging demo)",
SubTotal = 385.00m,
Total = 385.00m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-102),
LineItems =
{
new BillLineItem { AccountId = consumablesAccount?.Id, Description = "Aluminum Oxide #80 Grit — 100 lb bag", Quantity = 5, UnitPrice = 77.00m, Amount = 385.00m, DisplayOrder = 1 }
}
});
// 90+ day bucket: old Grainger equipment parts order (~98 days past due)
await AddBill(new Bill
{
VendorInvoiceNumber = "GRG-5001",
VendorId = (grainger ?? fallback).Id,
APAccountId = apAccount.Id,
BillDate = now.AddDays(-128),
DueDate = now.AddDays(-98),
Status = BillStatus.Open,
Terms = "Net 30",
Memo = "Oven conveyor motor parts — unpaid (aging demo)",
SubTotal = 492.00m,
Total = 492.00m,
AmountPaid = 0m,
CreatedAt = now.AddDays(-128),
LineItems =
{
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Conveyor Drive Motor 1/2 HP", Quantity = 1, UnitPrice = 312.00m, Amount = 312.00m, DisplayOrder = 1 },
new BillLineItem { AccountId = equipRepairsAccount?.Id, Description = "Drive Chain Sprocket (2-pack)", Quantity = 1, UnitPrice = 180.00m, Amount = 180.00m, DisplayOrder = 2 }
}
});
// Electric — current month (open) // Electric — current month (open)
await AddBill(new Bill await AddBill(new Bill
{ {
@@ -625,50 +793,55 @@ public partial class SeedDataService
seeded++; seeded++;
} }
// ── SHOP RENT — 3 months ───────────────────────────────────────────── // ── SHOP RENT — 12 months ─────────────────────────────────────────────
// Monthly rent payments going back 12 months fill the P&L expense chart.
var rentAccount = rentAcct ?? fallbackExpense; var rentAccount = rentAcct ?? fallbackExpense;
await AddExp(now.AddDays(-95), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 3 months ago"); for (int m = 12; m >= 1; m--)
await AddExp(now.AddDays(-65), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — 2 months ago"); await AddExp(now.AddDays(-(m * 30 + 2)), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, $"Shop rent — {m} month{(m == 1 ? "" : "s")} ago");
await AddExp(now.AddDays(-35), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — last month"); await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month");
await AddExp(now.AddDays(-3), null, rentAccount, checkingAccount, PaymentMethod.Check, 2_400.00m, "Shop rent — current month");
// ── NATURAL GAS — 3 months ─────────────────────────────────────────── // ── NATURAL GAS — 12 months ───────────────────────────────────────────
// Gas usage varies seasonally — higher in winter months.
var utilAccount = utilitiesAcct ?? fallbackExpense; var utilAccount = utilitiesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-88), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 218.44m, "Natural gas — 3 months ago"); decimal[] gasAmts = [310m, 295m, 260m, 218m, 185m, 172m, 168m, 179m, 196m, 225m, 255m, 241m];
await AddExp(now.AddDays(-58), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 241.60m, "Natural gas — 2 months ago"); for (int m = 12; m >= 1; m--)
await AddExp(now.AddDays(-28), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, 196.30m, "Natural gas — last month"); await AddExp(now.AddDays(-(m * 30 - 2)), null, utilAccount, checkingAccount, PaymentMethod.BankTransferACH, gasAmts[12 - m], $"Natural gas — {m} month{(m == 1 ? "" : "s")} ago");
// ── INSURANCE ───────────────────────────────────────────────────────── // ── INSURANCE — quarterly (4 payments over 12 months) ─────────────────
var insAccount = insuranceAcct ?? fallbackExpense; var insAccount = insuranceAcct ?? fallbackExpense;
await AddExp(now.AddDays(-90), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1"); await AddExp(now.AddDays(-365), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q1 (prior year)");
await AddExp(now.AddDays(-1), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2"); await AddExp(now.AddDays(-274), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q2");
await AddExp(now.AddDays(-182), null, insAccount, checkingAccount, PaymentMethod.Check, 785.00m, "Business liability insurance — quarterly premium Q3");
await AddExp(now.AddDays(-91), null, insAccount, checkingAccount, PaymentMethod.Check, 810.00m, "Business liability insurance — quarterly premium Q4 (rate increase)");
// ── MARKETING / ADVERTISING ─────────────────────────────────────────── // ── MARKETING / ADVERTISING — monthly for 12 months ──────────────────
var adAccount = advertisingAcct ?? fallbackExpense; var adAccount = advertisingAcct ?? fallbackExpense;
await AddExp(now.AddDays(-80), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign"); decimal[] adAmts = [120m, 120m, 135m, 150m, 150m, 165m, 150m, 165m, 175m, 175m, 175m, 175m];
await AddExp(now.AddDays(-50), null, adAccount, cc, PaymentMethod.CreditDebitCard, 150.00m, "Google Ads — local search campaign"); for (int m = 12; m >= 1; m--)
await AddExp(now.AddDays(-20), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — expanded local campaign"); await AddExp(now.AddDays(-(m * 30 + 5)), null, adAccount, cc, PaymentMethod.CreditDebitCard, adAmts[12 - m], "Google Ads — local search campaign");
await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly"); await AddExp(now.AddDays(-15), null, adAccount, cc, PaymentMethod.CreditDebitCard, 89.00m, "Yelp advertising — monthly");
await AddExp(now.AddDays(-5), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — current month"); await AddExp(now.AddDays(-5), null, adAccount, cc, PaymentMethod.CreditDebitCard, 175.00m, "Google Ads — current month");
// ── SOFTWARE SUBSCRIPTIONS ──────────────────────────────────────────── // ── SOFTWARE SUBSCRIPTIONS — 12 months ───────────────────────────────
var swAccount = officeSuppliesAcct ?? fallbackExpense; var swAccount = officeSuppliesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-90), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); for (int m = 12; m >= 1; m--)
await AddExp(now.AddDays(-60), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); await AddExp(now.AddDays(-(m * 30)), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
await AddExp(now.AddDays(-30), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly"); await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — current month");
await AddExp(now.AddDays(-2), null, swAccount, cc, PaymentMethod.CreditDebitCard, 29.00m, "QuickBooks subscription — monthly");
// ── OFFICE SUPPLIES ─────────────────────────────────────────────────── // ── OFFICE SUPPLIES — quarterly ───────────────────────────────────────
var offAccount = officeSuppliesAcct ?? fallbackExpense; var offAccount = officeSuppliesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-75), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens"); var firstVendorId = vendors.FirstOrDefault()?.Id;
await AddExp(now.AddDays(-8), vendors.FirstOrDefault()?.Id, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels"); await AddExp(now.AddDays(-270), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 58.90m, "Office supplies — printer paper, labels, pens");
await AddExp(now.AddDays(-180), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 63.40m, "Office supplies — printer paper, labels, pens");
await AddExp(now.AddDays(-90), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 71.25m, "Office supplies — printer paper, labels, pens");
await AddExp(now.AddDays(-8), firstVendorId, offAccount, cc, PaymentMethod.CreditDebitCard, 47.83m, "Office supplies — printer paper, pens, labels");
// ── BANK FEES ───────────────────────────────────────────────────────── // ── BANK FEES — 12 months ─────────────────────────────────────────────
var bankAccount = bankChargesAcct ?? fallbackExpense; var bankAccount = bankChargesAcct ?? fallbackExpense;
await AddExp(now.AddDays(-85), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 28.50m, "Monthly card processing fees"); decimal[] feeAmts = [24m, 25m, 26m, 28m, 27m, 29m, 28m, 30m, 31m, 29m, 31m, 32m];
await AddExp(now.AddDays(-55), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 31.20m, "Monthly card processing fees"); for (int m = 12; m >= 1; m--)
await AddExp(now.AddDays(-25), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 29.80m, "Monthly card processing fees"); await AddExp(now.AddDays(-(m * 30 - 1)), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, feeAmts[12 - m], "Monthly card processing fees");
await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees"); await AddExp(now.AddDays(-3), null, bankAccount, checkingAccount, PaymentMethod.BankTransferACH, 32.15m, "Monthly card processing fees — current month");
return seeded; return seeded;
} }
@@ -0,0 +1,138 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 90 days of facility clock-in/clock-out records for the 3 demo employees
/// (weekdays only), with realistic variation: full days, half days, late arrivals,
/// early dismissals, and random days off throughout the period.
/// </summary>
/// <remarks>
/// <para>
/// All times are stored in UTC. The shop operates on EST (UTC-5), so a 7:30 AM
/// clock-in becomes 12:30 UTC, and a 5:00 PM clock-out becomes 22:00 UTC.
/// </para>
/// <para>
/// The pattern for each (employee, weekday) is deterministic — derived from a
/// fixed hash of the employee index and day offset — so a reseed always produces
/// the same schedule. This prevents reports from looking different on every reset.
/// </para>
/// <para>
/// Day patterns (approximate distribution):
/// &bull; Full day (7:305:00, ~8.5 h paid) — ~55% of weekdays
/// &bull; Late arrival (9:0010:00 AM in, normal out) — ~15%
/// &bull; Early dismissal (normal in, 1:303:00 PM out) — ~12%
/// &bull; Half day (normal in, 12:0012:30 PM out, ~4 h) — ~10%
/// &bull; Day off (no entry generated) — ~8%
/// </para>
/// <para>
/// Idempotency: returns 0 immediately if any clock entries already exist for
/// this company.
/// </para>
/// </remarks>
private async Task<int> SeedEmployeeClockEntriesAsync(Company company)
{
var existingCount = await _context.Set<EmployeeClockEntry>()
.IgnoreQueryFilters()
.CountAsync(e => e.CompanyId == company.Id && !e.IsDeleted);
if (existingCount > 0) return 0;
var employees = await _userManager.Users
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id)
.OrderBy(u => u.Email)
.ToListAsync();
if (employees.Count == 0) return 0;
var now = DateTime.UtcNow.Date;
var entries = new List<EmployeeClockEntry>();
// Notes pools for each scenario, giving the time-clock view narrative variety.
string[] lateNotes = ["Dropped kids at school", "Doctor appointment AM", "Car trouble — called ahead", "Traffic on I-40", "Dentist — morning slot"];
string[] earlyNotes = ["Picking up parts from supplier", "Kid pickup from school", "Doctor appointment PM", "Personal errand — approved", "Left early — make-up hours tomorrow"];
string[] halfDayNotes = ["Half day — personal time", "Dentist then half day", "Training seminar AM only", "Family obligation — half day PTO", "Half day approved by manager"];
string[] dayOffNotes = []; // no entry = day off, no note needed
for (int empIdx = 0; empIdx < employees.Count; empIdx++)
{
var emp = employees[empIdx];
for (int daysBack = 90; daysBack >= 1; daysBack--)
{
var date = now.AddDays(-daysBack);
// Skip weekends
if (date.DayOfWeek == DayOfWeek.Saturday || date.DayOfWeek == DayOfWeek.Sunday)
continue;
// Deterministic pattern per (employee, day) — no Random state to maintain.
int hash = (empIdx * 37 + daysBack * 13) % 100;
// ~8% day off
if (hash < 8) continue;
// Base clock-in: 7:30 AM EST (12:30 UTC) ± small jitter
// Base clock-out: 5:00 PM EST (22:00 UTC) ± small jitter
int inMinuteUtc = 12 * 60 + 30 + (hash % 15); // 12:3012:44 UTC
int outMinuteUtc = 22 * 60 + (hash % 20); // 22:0022:19 UTC
string? noteText = null;
if (hash < 8)
{
// day off — already handled above
}
else if (hash < 18)
{
// ~10% half day — leave around noon
outMinuteUtc = 16 * 60 + 30 + (hash % 30); // 16:3016:59 UTC = 11:30 AM11:59 AM EST
noteText = halfDayNotes[hash % halfDayNotes.Length];
}
else if (hash < 33)
{
// ~15% late arrival — arrive 9:0010:30 AM EST (14:0015:30 UTC)
inMinuteUtc = 14 * 60 + (hash % 90); // 14:0015:29 UTC
noteText = lateNotes[hash % lateNotes.Length];
}
else if (hash < 45)
{
// ~12% early dismissal — leave 1:303:00 PM EST (18:3020:00 UTC)
outMinuteUtc = 18 * 60 + 30 + (hash % 90); // 18:3019:59 UTC
noteText = earlyNotes[hash % earlyNotes.Length];
}
// else: ~55% full day — use base in/out as-is
var clockIn = date.AddMinutes(inMinuteUtc);
var clockOut = date.AddMinutes(outMinuteUtc);
// Safety: never let clockOut <= clockIn (can happen on very short half-days)
if (clockOut <= clockIn) clockOut = clockIn.AddHours(4);
var hours = Math.Round((decimal)(clockOut - clockIn).TotalHours, 2);
entries.Add(new EmployeeClockEntry
{
UserId = emp.Id,
ClockInTime = clockIn,
ClockOutTime = clockOut,
HoursWorked = hours,
EntryType = ClockEntryType.Work,
Notes = noteText,
CompanyId = company.Id,
CreatedAt = clockIn,
});
}
}
if (entries.Count == 0) return 0;
await _context.Set<EmployeeClockEntry>().AddRangeAsync(entries);
await _context.SaveChangesAsync();
return entries.Count;
}
}
@@ -6,53 +6,39 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds 100 realistic customers (60 commercial, 40 individual/non-commercial) for /// Seeds customers at a random rate of 2-16 per calendar month across a date window that makes
/// the given company, spanning automotive, industrial, architectural, fitness, marine, /// the shop look like an ongoing business regardless of when the reset happens:
/// furniture, government, and specialty verticals. /// <list type="bullet">
/// <item>April through December reset: window starts January 1 of the current year.</item>
/// <item>January through March reset: window starts 6 months before the current month
/// (reaching into the prior year) so the shop does not appear brand-new.</item>
/// </list>
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Idempotency: returns (0, empty warnings) immediately if any non-deleted customers already /// The first 27 customers are always the hand-crafted anchor accounts (10 commercial,
/// exist for this company, preventing duplicate customer sets on repeated seed runs. /// 17 individual) inserted in a deterministic, index-stable order. <see cref="SeedJobsAsync"/>
/// maps customer indices 0&ndash;9 to specific commercial price profiles, so the commercial
/// anchors must always occupy the lowest database IDs for this company.
/// </para> /// </para>
/// <para> /// <para>
/// Each customer is inserted individually (rather than in a single <c>AddRange</c>) so that /// Anchors are spread evenly across the full window so that commercial accounts appear as
/// a duplicate-email collision on any single record is caught and converted to a warning /// established relationships. Procedural individual customers fill remaining slots to reach
/// rather than aborting the entire batch. The EF entity is detached on failure to prevent /// each month's random target.
/// the DbContext change-tracker from retrying the failed insert on the next
/// <c>SaveChangesAsync</c> call.
/// </para> /// </para>
/// <para> /// <para>
/// Pricing tiers (Standard, Silver, Gold, Platinum) are resolved by name from the company's /// Idempotency: returns 0 immediately if any non-deleted customers already exist for
/// already-seeded tiers. If a tier is missing the customer still inserts with no tier, /// this company (removed by the reset sweep before a full reseed).
/// rather than throwing.
/// </para>
/// <para>
/// Government/municipal customers (<c>Metro Transit Authority</c>, <c>Municipal Services Group</c>,
/// <c>Regional Airport Authority</c>, <c>County School District</c>) are seeded with
/// <c>IsTaxExempt = true</c> to demonstrate the tax-exempt workflow, matching the
/// production rule that tax-exempt customers get 0 % tax on quotes and invoices.
/// </para>
/// <para>
/// The two local helper functions <c>Comm()</c> and <c>Indiv()</c> reduce the per-row
/// line count; they are defined as local functions rather than private methods because
/// they capture the <c>company</c> parameter by closure and are only needed here.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed customers for.</param> /// <param name="company">The tenant company to seed customers for.</param>
/// <returns> /// <returns>A tuple of (seededCount, warnings) where warnings list skipped records.</returns>
/// A tuple of (<c>seededCount</c>, <c>warnings</c>) where <c>seededCount</c> is the number
/// of records actually inserted and <c>warnings</c> lists any customers that were skipped
/// (e.g. because the email already existed).
/// </returns>
private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company) private async Task<(int seededCount, List<string> warnings)> SeedCustomersAsync(Company company)
{ {
var warnings = new List<string>(); var warnings = new List<string>();
int seededCount = 0; int seededCount = 0;
int skippedCount = 0;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
// Early exit — same pattern as all other seeders
var existingCount = await _context.Set<Customer>() var existingCount = await _context.Set<Customer>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted); .CountAsync(c => c.CompanyId == company.Id && !c.IsDeleted);
@@ -70,191 +56,201 @@ public partial class SeedDataService
var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold"); var goldTier = tiers.FirstOrDefault(t => t.TierName == "Gold");
var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum"); var platinumTier = tiers.FirstOrDefault(t => t.TierName == "Platinum");
// ── Local helpers keep each customer to 2-3 lines ────────────────────── // ── Anchor customer builders (commercial and individual) ──────────────
//
// Comm() builds a commercial (B2B) Customer with credit limit, tax ID, and pricing tier.
// The LastContactDate formula scrambles the months value so that contacts are spread
// across the past 25 days rather than clustering on the same date for all customers.
Customer Comm(string co, string fn, string ln, string em, string ph, Customer Comm(string co, string fn, string ln, string em, string ph,
string city, string st, string zip, string terms, decimal credit, decimal bal, string city, string st, string zip, string terms, decimal credit, decimal bal,
string tax, PricingTier? tier, string notes, int months, bool taxExempt = false) => string tax, PricingTier? tier, string notes, bool taxExempt = false) =>
new Customer new Customer
{ {
CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em, CompanyName = co, ContactFirstName = fn, ContactLastName = ln, Email = em,
Phone = ph, City = city, State = st, ZipCode = zip, Phone = ph, City = city, State = st, ZipCode = zip,
IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal, IsCommercial = true, TaxId = tax, CreditLimit = credit, CurrentBalance = bal,
PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt, PaymentTerms = terms, PricingTierId = tier?.Id, IsTaxExempt = taxExempt,
IsActive = true, IsActive = true, GeneralNotes = notes, CompanyId = company.Id
LastContactDate = now.AddDays(-((months * 11 + 3) % 25 + 1)),
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
}; };
// Indiv() builds a non-commercial (retail) Customer with simpler fields:
// no credit limit, no tax ID, payment terms default to "Due on receipt".
Customer Indiv(string fn, string ln, string em, string ph, Customer Indiv(string fn, string ln, string em, string ph,
string city, string st, string zip, string notes, int months) => string city, string st, string zip, string notes) =>
new Customer new Customer
{ {
ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph, ContactFirstName = fn, ContactLastName = ln, Email = em, Phone = ph,
City = city, State = st, ZipCode = zip, City = city, State = st, ZipCode = zip,
IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true, IsCommercial = false, PaymentTerms = "Due on receipt", IsActive = true,
LastContactDate = now.AddDays(-((months * 17 + 5) % 50 + 5)), GeneralNotes = notes, CompanyId = company.Id
GeneralNotes = notes, CompanyId = company.Id, CreatedAt = now.AddMonths(-months)
}; };
var customers = new List<Customer> // ── 27 hand-crafted anchors — ORDER IS CRITICAL ───────────────────────
// SeedJobsAsync.CustomerProfile() maps indices 09 to commercial accounts
// and 1026 to known individual accounts. Never reorder these.
var anchors = new List<Customer>
{ {
// ─── Commercial Customers (60) ──────────────────────────────────── // Commercial (ci 09) — drive the Revenue by Customer report story
Comm("Carolina Fabrication", "Matt", "Henderson", "matt@carolinafab.com", "(919) 234-5678", "Raleigh", "NC", "27601", "Net 30", 50000m, 3200m, "56-7890123", platinumTier, "Largest account; structural steel and custom fab runs weekly"),
Comm("Apex Motorsports", "Chris", "Tanner", "ctanner@apexmotorsports.com", "(919) 345-6789", "Apex", "NC", "27502", "Net 30", 35000m, 2100m, "23-4567890", goldTier, "Race parts, chassis work, performance components"),
Comm("Triangle Offroad", "Jason", "Pruitt", "jpruitt@triangleoffroad.com", "(919) 456-7890", "Durham", "NC", "27701", "Net 15", 30000m, 1800m, "34-5678901", goldTier, "Jeep and truck accessory coatings; skid plates, bumpers"),
Comm("Smith Welding & Steel", "Bill", "Smith", "bsmith@smithwelding.com", "(919) 567-8901", "Garner", "NC", "27529", "Net 30", 20000m, 950m, "45-6789012", silverTier, "Custom steel fab, gates, railings; repeat orders monthly"),
Comm("Raleigh Architectural Metals", "Karen", "Morales", "kmorales@raleigharchitectural.com", "(919) 678-9012", "Raleigh", "NC", "27604", "Net 30", 25000m, 1400m, "67-8901234", goldTier, "Decorative ironwork, balcony railings, entry gates"),
Comm("East Coast Powderworks", "Tony", "Greco", "tgreco@eastcoastpw.com", "(252) 789-0123", "Greenville", "NC", "27858", "Net 15", 15000m, 620m, "78-9012345", silverTier, "Metal fab and fabrication outsourcing"),
Comm("Piedmont Metal Works", "Derek", "Shaw", "dshaw@piedmontmetalworks.com", "(336) 890-1234", "Greensboro", "NC", "27401", "Net 30", 10000m, 380m, "89-0123456", standardTier, "Heavy industrial parts, machine guards, conveyor frames"),
Comm("Cary Industrial Solutions", "Linda", "Patel", "lpatel@caryindustrial.com", "(919) 901-2345", "Cary", "NC", "27511", "Net 30", 18000m, 870m, "90-1234567", silverTier, "Equipment casings, pump housings, control panels"),
Comm("Durham Tech Equipment", "Ryan", "Blake", "rblake@durhamtech.com", "(919) 012-3456", "Durham", "NC", "27703", "Net 30", 28000m, 1150m, "01-2345678", goldTier, "Lab and research equipment frames and enclosures"),
Comm("Wake County Fleet Services", "Michelle", "Coleman", "mcoleman@wakecountyfleet.gov", "(919) 123-4560", "Raleigh", "NC", "27602", "Net 60", 75000m, 2800m, "56-7890124", platinumTier, "Government fleet contract — tax exempt; trailers, truck beds", true),
// Auto & Motorsports (12) // Individual (ci 1026) — residential work and hobby builds
Comm("Acme Manufacturing Corp", "John", "Smith", "john.smith@acmemfg.com", "(555) 234-5678", "Chicago", "IL", "60601", "Net 30", 50000m, 12500m, "12-3456789", platinumTier, "Large volume customer, weekly shipments", 18), Indiv("John", "Davis", "jdavis@email.com", "(919) 111-2222", "Raleigh", "NC", "27609", "Classic car restoration hobbyist; Ford Mustangs"),
Comm("Precision Auto Parts LLC", "Sarah", "Johnson", "sjohnson@precisionauto.com", "(555) 345-6789", "Detroit", "MI", "48201", "Net 30", 35000m, 8750m, "23-4567890", goldTier, "Automotive parts manufacturer", 15), Indiv("Sarah", "Jenkins", "sjenkins@email.com", "(919) 222-3333", "Durham", "NC", "27707", "Motorcycle customization; Harley-Davidson parts"),
Comm("Classic Wheel Restoration", "Robert", "Taylor", "rtaylor@classicwheels.com", "(555) 789-0123", "Phoenix", "AZ", "85001", "Net 15", 15000m, 3200m, "67-8901234", silverTier, "Classic car wheel specialist", 10), Indiv("Mike", "Thompson", "mthompson@email.com", "(919) 333-4444", "Apex", "NC", "27502", "Jeep Wrangler build, wheels and bumpers"),
Comm("MotorSports Custom Shop", "Chris", "Brown", "cbrown@motorsportscustom.com", "(555) 901-2345", "Indianapolis", "IN", "46201", "Net 15", 20000m, 9500m, "89-0123456", silverTier, "Performance parts and custom fabrication", 8), Indiv("Robert", "Miller", "rmiller@email.com", "(919) 444-5555", "Cary", "NC", "27513", "Patio furniture set, railings"),
Comm("Metro Automotive Group", "Frank", "DeNucci", "frank.dnucci@metroauto.com", "(555) 210-3311", "Detroit", "MI", "48202", "Net 30", 28000m, 6400m, "14-2233445", goldTier, "Multi-brand dealership network", 11), Indiv("Jennifer", "Clark", "jclark@email.com", "(919) 555-6666", "Chapel Hill", "NC", "27514", "Bicycle frame restoration; vintage road bikes"),
Comm("Coastal Customs & Fabrication", "Danny", "Morales", "dmorales@coastalcustoms.com", "(555) 887-6543", "San Diego", "CA", "92103", "Net 15", 18000m, 4100m, "22-3344556", silverTier, "Custom truck and SUV builds", 6), Indiv("David", "Wilson", "dwilson@email.com", "(919) 666-7777", "Wake Forest", "NC", "27587", "1969 Camaro restoration project, multiple phases"),
Comm("Desert Speed Shop", "Kyle", "Rennick", "kyle@desertspeedshop.com", "(555) 766-5544", "Scottsdale", "AZ", "85251", "Net 15", 12000m, 2800m, "33-4455667", standardTier, "Performance tuning and fabrication", 4), Indiv("Lisa", "Anderson", "landerson@email.com", "(919) 777-8888", "Morrisville", "NC", "27560", "Home decor metal pieces, garden art"),
Comm("Midwest Motorsports", "Troy", "Edelmann", "troy@midwestmotorsports.com", "(555) 342-1122", "Columbus", "OH", "43201", "Net 30", 22000m, 5300m, "44-5566778", goldTier, "Racing team equipment and parts", 9), Indiv("Thomas", "Harris", "tharris@email.com", "(252) 888-9999", "New Bern", "NC", "28560", "Boat trailer hardware, dock cleats"),
Comm("Track Day Performance", "Megan", "Schultz", "megan@trackdayperformance.com", "(555) 456-9988", "Charlotte", "NC", "28201", "Net 15", 16000m, 3700m, "55-6677889", silverTier, "Track prep and safety equipment", 5), Indiv("Karen", "White", "kwhite@email.com", "(919) 999-0000", "Fuquay-Varina","NC", "27526", "Antique fireplace grate and hardware restoration"),
Comm("Vintage Velocity Restorations", "Harold", "Pearce", "harold@vintagevelocity.com", "(555) 321-7654", "Nashville", "TN", "37201", "Net 30", 25000m, 6100m, "66-7788990", goldTier, "High-end vintage and classic car restoration", 13), Indiv("James", "Taylor", "jtaylor@email.com", "(919) 000-1111", "Garner", "NC", "27529", "1955 Ford F100 hot rod build"),
Comm("American Iron Custom Cycles", "Bret", "Conner", "bret@americanironcycles.com", "(555) 654-3210", "Milwaukee", "WI", "53201", "Net 15", 14000m, 3100m, "77-8899001", silverTier, "Custom motorcycle builds and parts", 7), Indiv("Michelle", "Brown", "mbrown@email.com", "(919) 131-4141", "Holly Springs","NC", "27540", "Outdoor furniture set, 6 chairs and table"),
Comm("All-American Auto Body", "Steve", "Kozlowski", "steve@allamericanautobody.com", "(555) 213-4567", "Cleveland", "OH", "44101", "Net 30", 20000m, 4800m, "88-9900112", goldTier, "Collision repair and custom coating", 10), Indiv("Chris", "Lee", "clee@email.com", "(984) 242-5252", "Raleigh", "NC", "27610", "Custom BMX frame &mdash; Candy Red"),
Indiv("Amanda", "Garcia", "agarcia@email.com", "(919) 353-6363", "Clayton", "NC", "27520", "Motorcycle frame and forks &mdash; Flat Black"),
// Industrial & Manufacturing (10) Indiv("Kevin", "Martinez", "kmartinez@email.com", "(919) 464-7474", "Wendell", "NC", "27591", "Snowmobile frame and tunnel"),
Comm("Industrial Furniture Co", "Jennifer","Anderson", "janderson@indfurniture.com", "(555) 890-1234", "Seattle", "WA", "98101", "Net 30", 30000m, 7800m, "78-9012345", goldTier, "Office and outdoor furniture manufacturer", 16), Indiv("Nancy", "Rodriguez", "nrodriguez@email.com", "(919) 575-8585", "Knightdale", "NC", "27545", "Wrought iron garden trellis and gate"),
Comm("Commercial HVAC Systems", "Kevin", "Garcia", "kgarcia@commercialhvac.com", "(555) 345-6780", "Atlanta", "GA", "30301", "Net 30", 32000m, 8900m, "23-4567891", goldTier, "HVAC ductwork and equipment casings", 17), Indiv("Brian", "Hall", "bhall@email.com", "(919) 686-9696", "Zebulon", "NC", "27597", "Utility trailer frame and hitch assembly"),
Comm("Agricultural Equipment Inc", "Sandra", "White", "swhite@agequipment.com", "(555) 678-9013", "Des Moines", "IA", "50301", "Net 30", 42000m, 16800m, "56-7890124", goldTier, "Farm equipment parts and implements", 19), Indiv("Patricia", "Young", "pyoung@email.com", "(919) 797-0707", "Louisburg", "NC", "27549", "Front porch railings &mdash; Gloss Black"),
Comm("Steel City Fabricators", "Tony", "Marchetti", "tony@steelcityfab.com", "(555) 412-3344", "Pittsburgh", "PA", "15201", "Net 30", 38000m, 10200m, "99-0011223", platinumTier, "Heavy structural steel fabrication", 14),
Comm("Precision Metal Works", "Diane", "Tran", "diane@precisionmetalworks.com", "(555) 503-2211", "Portland", "OR", "97205", "Net 30", 26000m, 5900m, "10-1122334", goldTier, "CNC machined parts and assemblies", 12),
Comm("Continental Manufacturing", "Phil", "Stavros", "pstavros@continentalmfg.com", "(555) 216-8877", "Cleveland", "OH", "44115", "Net 45", 55000m, 18400m, "21-2233445", platinumTier, "Industrial component manufacturing", 20),
Comm("Eagle Industrial Coatings", "Deb", "Hensley", "deb@eagleindustrialcoatings.com", "(555) 317-5566", "Indianapolis", "IN", "46204", "Net 30", 19000m, 4300m, "32-3344556", silverTier, "Subcontract coating for industrial parts", 7),
Comm("Summit Metal Fabricators", "Russ", "Fontaine", "russ@summitmetalfab.com", "(555) 720-4433", "Denver", "CO", "80202", "Net 30", 31000m, 7700m, "43-4455667", goldTier, "Custom metal fabrication and welding", 11),
Comm("Iron Horse Manufacturing", "Craig", "Bukowski", "craig@ironhorsemfg.com", "(555) 414-7788", "Milwaukee", "WI", "53202", "Net 30", 24000m, 5600m, "54-5566778", goldTier, "Heavy equipment components and frames", 9),
Comm("Pacific Metal Works", "Yuki", "Tanaka", "ytanaka@pacificmetalworks.com", "(555) 206-3322", "Seattle", "WA", "98104", "Net 15", 17000m, 3800m, "65-6677889", silverTier, "Sheet metal fabrication and finishing", 6),
// Architectural & Construction (8)
Comm("Urban Railings & Gates", "Michael", "Chen", "mchen@urbanrailings.com", "(555) 456-7890", "San Francisco", "CA", "94102", "Net 15", 25000m, 5200m, "34-5678901", silverTier, "Ornamental iron railings and gates", 12),
Comm("Heritage Architectural Metalworks","Thomas","Miller", "tmiller@heritagemetal.com", "(555) 123-4567", "Charleston", "SC", "29401", "Net 30", 28000m, 6700m, "01-2345678", goldTier, "Historic restoration and custom architectural pieces",13),
Comm("Skyline Structural Steel", "Marcus", "Webb", "mwebb@skylinsteel.com", "(555) 312-9876", "Chicago", "IL", "60607", "Net 45", 65000m, 22000m, "76-7788990", platinumTier, "Commercial and industrial structural steel", 22),
Comm("Premier Fence & Gate Co", "Lori", "Hale", "lori@premierfenceandgate.com", "(555) 602-1199", "Phoenix", "AZ", "85004", "Net 30", 23000m, 5400m, "87-8899001", goldTier, "Residential and commercial fencing", 8),
Comm("Modern Railing Systems", "Evan", "Choi", "echoi@modernrailingsystems.com", "(555) 415-8844", "San Jose", "CA", "95110", "Net 30", 27000m, 6200m, "98-9900112", goldTier, "Interior and exterior railing design", 10),
Comm("Coastal Aluminum Products", "Patty", "Larson", "plarson@coastalaluminum.com", "(555) 904-3366", "Tampa", "FL", "33601", "Net 30", 21000m, 4700m, "09-0011223", goldTier, "Aluminum windows, doors, and structures", 7),
Comm("Metro Door & Window", "Sam", "Petrov", "spetrov@metrodoorwindow.com", "(555) 718-4455", "Brooklyn", "NY", "11201", "Net 30", 29000m, 7100m, "20-1122334", goldTier, "Commercial door and window systems", 11),
Comm("Rocky Mountain Ironworks", "Buck", "Ramsey", "buck@rockymtnironworks.com", "(555) 303-6677", "Denver", "CO", "80203", "Net 15", 18500m, 4100m, "31-2233445", silverTier, "Custom wrought iron and steel artisan work", 5),
// Fitness & Recreation (5)
Comm("Fitness Equipment Solutions", "Lisa", "Martinez", "lmartinez@fitequip.com", "(555) 567-8901", "Austin", "TX", "78701", "Net 30", 40000m, 15600m, "45-6789012", goldTier, "Gym equipment frames and accessories", 14),
Comm("Playground Equipment USA", "Nancy", "Martinez", "nmartinez@playgroundusa.com", "(555) 456-7891", "Portland", "OR", "97201", "Net 30", 38000m, 14500m, "34-5678902", platinumTier, "Commercial playground equipment manufacturer", 22),
Comm("Diamond Fitness Equipment", "Lamar", "Okafor", "lamar@diamondfitness.com", "(555) 713-2288", "Houston", "TX", "77002", "Net 30", 33000m, 8100m, "42-3344556", goldTier, "Commercial gym and fitness center equipment", 9),
Comm("Peak Performance Products", "Stacy", "Owens", "stacy@peakperformanceproducts.com", "(555) 503-7711", "Eugene", "OR", "97401", "Net 15", 16000m, 3500m, "53-4455667", silverTier, "Outdoor fitness and sports equipment", 6),
Comm("All-Star Sports Equipment", "Jerome", "Watkins", "jwatkins@allstarsports.com", "(555) 314-5533", "St. Louis", "MO", "63101", "Net 30", 22000m, 5100m, "64-5566778", goldTier, "Team sports equipment and facilities", 8),
// Marine (4)
Comm("Marine Equipment Corp", "Patricia","Wilson", "pwilson@marineequip.com", "(555) 234-5679", "Miami", "FL", "33101", "Net 30", 35000m, 11400m, "12-3456780", silverTier, "Boat hardware and marine fittings", 11),
Comm("Gulf Coast Marine Supply", "Hector", "Vega", "hvega@gulfcoastmarine.com", "(555) 985-6644", "New Orleans", "LA", "70112", "Net 30", 28000m, 6800m, "75-6677889", goldTier, "Commercial and recreational marine hardware", 9),
Comm("Pacific Yacht Hardware", "Erin", "Nakamura", "enakamura@pacificyacht.com", "(555) 310-8822", "Long Beach", "CA", "90802", "Net 15", 20000m, 4500m, "86-7788990", silverTier, "High-end yacht fittings and hardware", 6),
Comm("Lakeside Boat Works", "Walt", "Bauer", "walt@lakesideboatworks.com", "(555) 616-3311", "Grand Rapids", "MI", "49501", "Net 30", 15000m, 3200m, "97-8899001", silverTier, "Freshwater boat repair and custom builds", 4),
// Furniture & Commercial (5)
Comm("Office Systems International", "Brian", "Lee", "blee@officesystems.com", "(555) 567-8902", "Dallas", "TX", "75201", "Net 15", 27000m, 5600m, "45-6789013", silverTier, "Office furniture components and accessories", 9),
Comm("Retail Display Solutions", "Gina", "Russo", "gruso@retaildisplay.com", "(555) 312-6644", "Chicago", "IL", "60608", "Net 30", 19000m, 4300m, "08-9900112", goldTier, "Retail shelving, fixtures, and displays", 7),
Comm("Restaurant Equipment Co", "Marco", "Benetti", "mbenetti@restaurantequipment.com", "(555) 305-1122", "Miami", "FL", "33102", "Net 30", 24000m, 5800m, "19-0011223", goldTier, "Commercial kitchen and restaurant equipment", 10),
Comm("Outdoor Living Products", "Cheryl", "Dobbs", "cdobbs@outdoorlivingproducts.com", "(555) 480-7799", "Tempe", "AZ", "85281", "Net 30", 21000m, 4600m, "30-1122334", goldTier, "Patio and outdoor furniture manufacturer", 8),
Comm("Commercial Shelving Systems", "Ray", "Obasi", "robasi@commercialshelving.com", "(555) 832-5544", "Houston", "TX", "77003", "Net 30", 16000m, 3400m, "41-2233445", silverTier, "Warehouse and retail shelving solutions", 5),
// Energy, Transit & Government (7)
Comm("Metro Transit Authority", "David", "Williams", "dwilliams@metrota.gov", "(555) 678-9012", "Boston", "MA", "02101", "Net 60", 75000m, 22000m, "56-7890123", platinumTier, "Government transit contract — tax exempt", 24, true),
Comm("Green Energy Solutions", "Amanda", "Davis", "adavis@greenenergy.com", "(555) 012-3456", "Denver", "CO", "80201", "Net 30", 45000m, 18200m, "90-1234567", platinumTier, "Solar panel frames and mounting hardware", 20),
Comm("Solar Power Systems Inc", "Neil", "Ostrowski", "nostrowski@solarpowersys.com", "(555) 408-4411", "San Jose", "CA", "95112", "Net 30", 36000m, 9200m, "52-3344556", goldTier, "Solar racking and structural components", 11),
Comm("Wind Energy Components", "Tara", "Haas", "thaas@windenergy.com", "(555) 605-8833", "Austin", "TX", "78702", "Net 45", 48000m, 15600m, "63-4455667", platinumTier, "Wind turbine hardware and mounting systems", 16),
Comm("Municipal Services Group", "Roy", "Nkosi", "rnkosi@municipalservices.gov", "(555) 608-2233", "Sacramento", "CA", "95814", "Net 60", 60000m, 19800m, "74-5566778", platinumTier, "City infrastructure and public works — tax exempt", 28, true),
Comm("Regional Airport Authority", "Lisa", "Crane", "lcrane@regionairport.gov", "(555) 904-5511", "Tampa", "FL", "33602", "Net 60", 55000m, 17200m, "85-6677889", platinumTier, "Airport infrastructure — tax exempt", 21, true),
Comm("County School District", "Terry", "Vance", "tvance@countyschools.edu", "(555) 317-8866", "Indianapolis", "IN", "46205", "Net 60", 40000m, 12500m, "96-7788990", goldTier, "School facility equipment — tax exempt", 15, true),
// Specialty (9)
Comm("Medical Equipment Corp", "Paula", "Jennings", "pjennings@medicalequip.com", "(555) 215-6655", "Philadelphia", "PA", "19103", "Net 30", 42000m, 12800m, "07-8899001", goldTier, "Medical and laboratory equipment frames", 13),
Comm("Food Processing Equipment", "Luis", "Espinoza", "lespinoza@foodprocessingequip.com", "(555) 816-3388", "Indianapolis", "IN", "46206", "Net 30", 31000m, 7400m, "18-9900112", goldTier, "Food-safe coating for processing equipment", 9),
Comm("Security Solutions Group", "Dale", "Pratt", "dpratt@securitysolutionsgrp.com", "(555) 214-4477", "Dallas", "TX", "75202", "Net 30", 26000m, 5900m, "29-0011223", goldTier, "Security enclosures and equipment housing", 8),
Comm("Mining Equipment Corp", "Rex", "Harmon", "rharmon@miningequip.com", "(555) 801-6622", "Salt Lake City", "UT", "84101", "Net 30", 48000m, 16400m, "40-1122334", platinumTier, "Mining and extraction equipment components", 17),
Comm("Construction Equipment Co", "Wayne", "Briggs", "wbriggs@constructionequipco.com", "(555) 918-7733", "Oklahoma City", "OK", "73101", "Net 30", 37000m, 10100m, "51-2233445", goldTier, "Construction and earthmoving equipment parts", 12),
Comm("Water Treatment Systems", "Irene", "Kamau", "ikamau@watertreatmentsys.com", "(555) 503-9944", "Portland", "OR", "97206", "Net 45", 44000m, 14100m, "62-3344556", platinumTier, "Municipal and industrial water treatment equipment", 18),
Comm("Rail Equipment Systems", "Doug", "Stafford", "dstafford@railequipmentsys.com", "(555) 312-7766", "Chicago", "IL", "60609", "Net 45", 52000m, 17800m, "73-4455667", platinumTier, "Railway maintenance and rolling stock equipment", 23),
Comm("Telecommunications Tower Co", "Maggie", "Solis", "msolis@telcotowers.com", "(555) 469-5588", "Dallas", "TX", "75203", "Net 30", 35000m, 9500m, "84-5566778", goldTier, "Cell tower hardware and mounting equipment", 10),
Comm("Data Center Infrastructure", "Bo", "Kimura", "bkimura@datacenterinfra.com", "(555) 408-2266", "San Jose", "CA", "95113", "Net 30", 29000m, 7200m, "95-6677889", goldTier, "Server rack frames and data center equipment", 7),
// ─── Individual / Non-Commercial Customers (40) ───────────────────
Indiv("James", "Thompson", "jthompson@email.com", "(555) 111-2222", "Los Angeles", "CA", "90001", "Classic car restoration hobbyist", 6),
Indiv("Mary", "Harris", "mharris@email.com", "(555) 222-3333", "Houston", "TX", "77001", "Patio furniture refurbishment", 4),
Indiv("William", "Clark", "wclark@email.com", "(555) 333-4444", "Philadelphia", "PA", "19101", "Motorcycle customization", 7),
Indiv("Elizabeth","Lewis", "elewis@email.com", "(555) 444-5555", "Phoenix", "AZ", "85001", "Garden furniture restoration", 3),
Indiv("Richard", "Walker", "rwalker@email.com", "(555) 555-6666", "San Antonio", "TX", "78201", "Custom bike parts", 5),
Indiv("Barbara", "Hall", "bhall@email.com", "(555) 666-7777", "San Diego", "CA", "92101", "Antique furniture hardware", 2),
Indiv("Joseph", "Allen", "jallen@email.com", "(555) 777-8888", "Dallas", "TX", "75201", "Hot rod restoration", 8),
Indiv("Susan", "Young", "syoung@email.com", "(555) 888-9999", "San Jose", "CA", "95101", "Home décor projects", 1),
Indiv("Charles", "King", "cking@email.com", "(555) 999-0000", "Austin", "TX", "78701", "Vintage car parts", 5),
Indiv("Linda", "Wright", "lwright@email.com", "(555) 000-1111", "Jacksonville", "FL", "32201", "Outdoor metalwork restoration", 3),
Indiv("Gary", "Nelson", "gnelson@email.com", "(555) 131-4141", "Minneapolis", "MN", "55401", "Snowmobile frame and parts", 2),
Indiv("Carol", "Evans", "carol.evans@email.com", "(555) 242-5252", "Portland", "OR", "97207", "Vintage bicycle restoration", 1),
Indiv("Kenneth", "Scott", "kscott@email.com", "(555) 353-6363", "Baltimore", "MD", "21201", "Antique tool restoration", 3),
Indiv("Helen", "Green", "hgreen@email.com", "(555) 464-7474", "Memphis", "TN", "38101", "Wrought iron bed frame", 4),
Indiv("Donald", "Baker", "dbaker@email.com", "(555) 575-8585", "Louisville", "KY", "40201", "Classic truck restoration", 6),
Indiv("Donna", "Adams", "dadams@email.com", "(555) 686-9696", "Richmond", "VA", "23218", "Outdoor light fixture set", 2),
Indiv("Steven", "Nelson", "steven.n@email.com", "(555) 797-0707", "Columbus", "OH", "43202", "Motorcycle frame and tank", 5),
Indiv("Patricia", "Carter", "pcarter@email.com", "(555) 808-1818", "Austin", "TX", "78703", "Patio table and chair set — 6pc", 3),
Indiv("Mark", "Mitchell", "mmitchell@email.com", "(555) 919-2929", "Denver", "CO", "80204", "Car wheels — set of 4", 1),
Indiv("Sandra", "Perez", "sperez@email.com", "(555) 020-3030", "El Paso", "TX", "79901", "Spiral staircase railing", 4),
Indiv("George", "Roberts", "groberts@email.com", "(555) 141-4242", "Fort Worth", "TX", "76101", "Boat trailer frame", 3),
Indiv("Kathleen", "Turner", "kturner@email.com", "(555) 252-5353", "Nashville", "TN", "37202", "Fireplace grate and screen", 2),
Indiv("Eric", "Phillips", "ephillips@email.com", "(555) 363-6464", "Seattle", "WA", "98105", "Mountain bike frame", 1),
Indiv("Sharon", "Campbell", "scampbell@email.com", "(555) 474-7575", "Boston", "MA", "02102", "Iron garden bench set", 5),
Indiv("Larry", "Parker", "lparker@email.com", "(555) 585-8686", "Detroit", "MI", "48203", "Classic Mustang wheels and trim", 8),
Indiv("Shirley", "Evans", "shevans@email.com", "(555) 696-9797", "Charlotte", "NC", "28202", "Deck railing system", 3),
Indiv("Timothy", "Edwards", "tedwards@email.com", "(555) 707-0808", "Memphis", "TN", "38102", "ATV frame and fenders", 2),
Indiv("Angela", "Collins", "acollins@email.com", "(555) 818-1919", "Las Vegas", "NV", "89101", "Casino chair legs — set of 24", 4),
Indiv("Harold", "Stewart", "hstewart@email.com", "(555) 929-2020", "Tucson", "AZ", "85701", "Vintage pickup restoration parts", 6),
Indiv("Pamela", "Sanchez", "psanchez@email.com", "(555) 030-3131", "Sacramento", "CA", "95815", "Wrought iron wine rack", 1),
Indiv("Edward", "Morris", "emorris@email.com", "(555) 141-4343", "Raleigh", "NC", "27601", "Trailer hitch and receiver set", 2),
Indiv("Frances", "Rogers", "frogers@email.com", "(555) 252-5454", "Minneapolis", "MN", "55402", "Mid-century chair frames — 4pc", 3),
Indiv("Phillip", "Reed", "preed@email.com", "(555) 363-6565", "Omaha", "NE", "68101", "Go-kart frame and roll cage", 1),
Indiv("Ruth", "Cook", "rcook@email.com", "(555) 474-7676", "Tulsa", "OK", "74101", "Farmhouse shelving brackets — large set", 2),
Indiv("Andrew", "Morgan", "amorgan@email.com", "(555) 585-8787", "Atlanta", "GA", "30302", "Drift car cage and subframe", 4),
Indiv("Mildred", "Bell", "mbell@email.com", "(555) 696-9898", "Cincinnati", "OH", "45201", "Garden gate and fence panels", 5),
Indiv("Ralph", "Murphy", "rmurphy@email.com", "(555) 707-0909", "Fresno", "CA", "93701", "Custom motorcycle exhaust system", 3),
Indiv("Lois", "Rivera", "lrivera@email.com", "(555) 818-1010", "Corpus Christi", "TX", "78401", "Outdoor kitchen frame and brackets", 2),
Indiv("Roy", "Cooper", "rcooper@email.com", "(555) 929-2121", "Arlington", "TX", "76001", "Vintage tractor restoration parts", 7),
Indiv("Vera", "Richardson","vrichardson@email.com", "(555) 030-3232", "Lexington", "KY", "40502", "Wrought iron headboard and footboard", 4),
}; };
// Add customers one at a time to handle duplicates gracefully // ── Date window ───────────────────────────────────────────────────────
foreach (var customer in customers) // Post-Q1 (AprDec): start from January 1 of the current year so the full
// year-to-date story is visible in charts.
// Q1 (JanMar): look back 6 months into the prior year so the shop does not
// appear to have opened the day of the reset.
var windowStart = now.Month <= 3
? new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-6)
: new DateTime(now.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc);
int windowMonths = (now.Year - windowStart.Year) * 12
+ (now.Month - windowStart.Month) + 1;
var rng = new Random();
// Each month gets a random target between 2 and 16 so the customer-growth
// graph looks like a real shop instead of a perfectly flat 15/month line.
var monthlyTargets = Enumerable.Range(0, windowMonths)
.Select(_ => rng.Next(2, 17))
.ToArray();
// Spread anchors evenly across the full window so that commercial accounts
// appear as established customers rather than all clustering in one month.
for (int i = 0; i < anchors.Count; i++)
{
int slot = i * windowMonths / anchors.Count;
slot = Math.Clamp(slot, 0, windowMonths - 1);
var baseDate = windowStart.AddMonths(slot);
int day = Math.Min(3 + (i % 20), DateTime.DaysInMonth(baseDate.Year, baseDate.Month));
anchors[i].CreatedAt = new DateTime(baseDate.Year, baseDate.Month, day, 8, 0, 0, DateTimeKind.Utc);
}
// Count anchors per month slot to know how many procedural customers each
// month still needs to reach that slot's random target.
var anchorsPerSlot = new int[windowMonths];
foreach (var a in anchors)
{
int slot = (a.CreatedAt.Year - windowStart.Year) * 12
+ (a.CreatedAt.Month - windowStart.Month);
anchorsPerSlot[Math.Clamp(slot, 0, windowMonths - 1)]++;
}
// Insert anchors in deterministic order (commercial first, then individual).
// This guarantees that job seeder indices 0-9 = commercial, 10-26 = individual.
foreach (var anchor in anchors)
{ {
try try
{ {
var existingCustomer = await _context.Set<Customer>() var dup = await _context.Set<Customer>().IgnoreQueryFilters()
.IgnoreQueryFilters() .FirstOrDefaultAsync(c => c.Email == anchor.Email && c.CompanyId == company.Id && !c.IsDeleted);
.FirstOrDefaultAsync(c => c.Email == customer.Email if (dup != null) { warnings.Add($"Skipped anchor {anchor.Email} — already exists"); continue; }
&& c.CompanyId == company.Id && !c.IsDeleted); await _context.Set<Customer>().AddAsync(anchor);
if (existingCustomer != null)
{
skippedCount++;
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}";
warnings.Add($"⊘ Skipped: {name} — email {customer.Email} already exists");
continue;
}
await _context.Set<Customer>().AddAsync(customer);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
seededCount++; seededCount++;
} }
catch (Exception ex) catch (Exception ex)
{ {
skippedCount++; warnings.Add($"Skipped anchor {anchor.Email} — {GetFriendlyErrorMessage(ex, "customer")}");
var name = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}"; if (_context.Entry(anchor).State != Microsoft.EntityFrameworkCore.EntityState.Detached)
warnings.Add($"⊘ Skipped: {name} — {GetFriendlyErrorMessage(ex, "customer")}"); _context.Entry(anchor).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
if (_context.Entry(customer).State != EntityState.Detached) }
_context.Entry(customer).State = EntityState.Detached; }
// ── Name and location pools for procedural fill customers ─────────────
string[] firstNames =
{
"Liam", "Emma", "Noah", "Olivia", "Oliver", "Charlotte", "Elijah", "Amelia",
"Aiden", "Harper", "Lucas", "Evelyn", "Mason", "Grace", "Ethan", "Sofia",
"Logan", "Chloe", "Caleb", "Victoria", "Ryan", "Zoe", "Nathan", "Lily",
"Carter", "Aria", "Dylan", "Nora", "Brandon", "Hazel",
};
string[] lastNames =
{
"Mitchell", "Reyes", "Turner", "Phillips", "Campbell", "Parker",
"Evans", "Edwards", "Collins", "Stewart", "Fletcher", "Morris",
"Morgan", "Bell", "Murphy", "Bailey", "Rivera", "Cooper",
"Richardson","Cox", "Howard", "Ward", "Torres", "Peterson",
"Gray", "Ramirez", "James", "Watson", "Brooks", "Kelly",
};
string[] cities = { "Raleigh", "Durham", "Cary", "Apex", "Wake Forest", "Garner",
"Morrisville", "Fuquay-Varina", "Holly Springs", "Knightdale",
"Wendell", "Clayton", "Zebulon", "Pittsboro", "Lillington" };
string[] zips = { "27601", "27707", "27511", "27502", "27587", "27529",
"27560", "27526", "27540", "27545",
"27591", "27520", "27597", "27312", "27546" };
string[] domains = { "gmail.com", "yahoo.com", "outlook.com", "hotmail.com", "icloud.com", "aol.com" };
// Fill each month slot to the target using procedurally generated individual customers.
int genIdx = 0;
for (int slot = 0; slot < windowMonths; slot++)
{
int needed = Math.Max(0, monthlyTargets[slot] - anchorsPerSlot[slot]);
var monthDate = windowStart.AddMonths(slot);
int daysInMo = DateTime.DaysInMonth(monthDate.Year, monthDate.Month);
var monthBase = new DateTime(monthDate.Year, monthDate.Month, 1, 9, 0, 0, DateTimeKind.Utc);
for (int slotI = 0; slotI < needed; slotI++, genIdx++)
{
string fn = firstNames[genIdx % firstNames.Length];
string ln = lastNames[(genIdx / firstNames.Length) % lastNames.Length];
string city = cities[genIdx % cities.Length];
string zip = zips[genIdx % zips.Length];
string email = $"{fn.ToLower()}{ln.ToLower()}{genIdx + 1}@{domains[genIdx % domains.Length]}";
string phone = $"({600 + genIdx % 400}) {200 + genIdx % 800:D3}-{genIdx % 9000 + 1000:D4}";
int day = needed > 1
? Math.Min(1 + (slotI * (daysInMo - 2) / needed), daysInMo - 1)
: 14;
var gen = new Customer
{
ContactFirstName = fn,
ContactLastName = ln,
Email = email,
Phone = phone,
City = city,
State = "NC",
ZipCode = zip,
IsCommercial = false,
PaymentTerms = "Due on receipt",
IsActive = true,
CompanyId = company.Id,
CreatedAt = monthBase.AddDays(day),
};
try
{
await _context.Set<Customer>().AddAsync(gen);
await _context.SaveChangesAsync();
seededCount++;
}
catch (Exception ex)
{
warnings.Add($"Skipped generated customer {email} — {GetFriendlyErrorMessage(ex, "customer")}");
if (_context.Entry(gen).State != Microsoft.EntityFrameworkCore.EntityState.Detached)
_context.Entry(gen).State = Microsoft.EntityFrameworkCore.EntityState.Detached;
}
} }
} }
@@ -57,7 +57,7 @@ public partial class SeedDataService
{ {
new Equipment new Equipment
{ {
EquipmentName = "Batch Powder Coating Oven #1", EquipmentName = "Main Batch Oven",
EquipmentNumber = $"{company.CompanyCode}-OVN-001", EquipmentNumber = $"{company.CompanyCode}-OVN-001",
EquipmentType = "Oven", EquipmentType = "Oven",
Manufacturer = "Reliant Finishing Systems", Manufacturer = "Reliant Finishing Systems",
@@ -78,7 +78,7 @@ public partial class SeedDataService
}, },
new Equipment new Equipment
{ {
EquipmentName = "Batch Powder Coating Oven #2", EquipmentName = "Small Batch Oven",
EquipmentNumber = $"{company.CompanyCode}-OVN-002", EquipmentNumber = $"{company.CompanyCode}-OVN-002",
EquipmentType = "Oven", EquipmentType = "Oven",
Manufacturer = "Reliant Finishing Systems", Manufacturer = "Reliant Finishing Systems",
@@ -99,7 +99,7 @@ public partial class SeedDataService
}, },
new Equipment new Equipment
{ {
EquipmentName = "Automated Powder Coating Booth #1", EquipmentName = "Powder Coating Booth",
EquipmentNumber = $"{company.CompanyCode}-BOOTH-001", EquipmentNumber = $"{company.CompanyCode}-BOOTH-001",
EquipmentType = "Spray Booth", EquipmentType = "Spray Booth",
Manufacturer = "Nordson Corporation", Manufacturer = "Nordson Corporation",
@@ -120,7 +120,7 @@ public partial class SeedDataService
}, },
new Equipment new Equipment
{ {
EquipmentName = "Manual Powder Coating Booth #2", EquipmentName = "Manual Powder Booth",
EquipmentNumber = $"{company.CompanyCode}-BOOTH-002", EquipmentNumber = $"{company.CompanyCode}-BOOTH-002",
EquipmentType = "Spray Booth", EquipmentType = "Spray Booth",
Manufacturer = "Columbia Coatings", Manufacturer = "Columbia Coatings",
@@ -162,7 +162,7 @@ public partial class SeedDataService
}, },
new Equipment new Equipment
{ {
EquipmentName = "Media Blast Room", EquipmentName = "Pressure Pot Blaster",
EquipmentNumber = $"{company.CompanyCode}-BLAST-002", EquipmentNumber = $"{company.CompanyCode}-BLAST-002",
EquipmentType = "Sandblaster", EquipmentType = "Sandblaster",
Manufacturer = "Clemco Industries", Manufacturer = "Clemco Industries",
@@ -183,7 +183,7 @@ public partial class SeedDataService
}, },
new Equipment new Equipment
{ {
EquipmentName = "Rotary Screw Air Compressor", EquipmentName = "Air Compressor",
EquipmentNumber = $"{company.CompanyCode}-COMP-001", EquipmentNumber = $"{company.CompanyCode}-COMP-001",
EquipmentType = "Compressor", EquipmentType = "Compressor",
Manufacturer = "Atlas Copco", Manufacturer = "Atlas Copco",
@@ -204,28 +204,28 @@ public partial class SeedDataService
}, },
new Equipment new Equipment
{ {
EquipmentName = "Overhead Conveyor System", EquipmentName = "Forklift",
EquipmentNumber = $"{company.CompanyCode}-CONV-001", EquipmentNumber = $"{company.CompanyCode}-FORK-001",
EquipmentType = "Conveyor", EquipmentType = "Forklift",
Manufacturer = "Pacline Conveyors", Manufacturer = "Toyota",
Model = "PAC-500 Overhead", Model = "8FGCU25",
SerialNumber = "PAC50034521", SerialNumber = "PAC50034521",
PurchaseDate = DateTime.UtcNow.AddYears(-4), PurchaseDate = DateTime.UtcNow.AddYears(-4),
PurchasePrice = 52000m, PurchasePrice = 28000m,
WarrantyExpiration = DateTime.UtcNow.AddYears(-2), WarrantyExpiration = DateTime.UtcNow.AddYears(-2),
Status = EquipmentStatus.Operational, Status = EquipmentStatus.Operational,
Location = "Main Production Line", Location = "Loading Area",
RecommendedMaintenanceIntervalDays = 180, RecommendedMaintenanceIntervalDays = 180,
LastMaintenanceDate = DateTime.UtcNow.AddDays(-120), LastMaintenanceDate = DateTime.UtcNow.AddDays(-90),
NextScheduledMaintenance = DateTime.UtcNow.AddDays(60), NextScheduledMaintenance = DateTime.UtcNow.AddDays(90),
Notes = "500 lb capacity overhead conveyor with power and free sections", Notes = "5,000 lb capacity propane forklift — used for loading/unloading customer parts",
IsActive = true, IsActive = true,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = DateTime.UtcNow.AddYears(-4) CreatedAt = DateTime.UtcNow.AddYears(-4)
}, },
new Equipment new Equipment
{ {
EquipmentName = "Parts Washer System", EquipmentName = "Wash Station",
EquipmentNumber = $"{company.CompanyCode}-WASH-001", EquipmentNumber = $"{company.CompanyCode}-WASH-001",
EquipmentType = "Washer", EquipmentType = "Washer",
Manufacturer = "Better Engineering", Manufacturer = "Better Engineering",
@@ -0,0 +1,411 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
// ═══════════════════════════════════════════════════════════════════════════
// Job Notes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds realistic shop-floor notes on a subset of jobs so that the job
/// detail view looks lived-in. Roughly 70% of jobs receive 1&ndash;2 notes;
/// the selection and text are deterministic so every reset produces the same set.
/// </summary>
private async Task<int> SeedJobNotesAsync(Company company)
{
var existingCount = await _context.Set<JobNote>()
.IgnoreQueryFilters()
.CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
if (existingCount > 0) return 0;
var jobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
if (jobs.Count == 0) return 0;
string[] internalNotes =
[
"Customer confirmed pickup Friday PM — call ahead.",
"Requires extra masking around mounting holes — 6 total.",
"Rush job — prioritize over batch queue.",
"Custom color mix: 70% Gloss Black + 30% Charcoal Grey.",
"Check for pitting before coating — surface prep critical.",
"Customer wants to inspect before final cure.",
"Sandblast to bare metal — all previous coating must come off.",
"Note: customer bringing additional parts next week, hold space.",
"Frame sections must be coated before assembly — check sequence.",
"QC check required — previous run had adhesion issue with this powder.",
"Customer paid deposit via check #4412.",
"Temperature-sensitive substrate — confirm oven profile before load.",
"Left voicemail re: delivery date — awaiting callback.",
"Batch with Triangle Offroad order if timing works out.",
"Hanging rod #3 reserved for this run.",
];
string[] externalNotes =
[
"Customer requested matte finish — confirmed in writing.",
"Delivery arranged for Thursday 9am&ndash;12pm.",
"Customer approved color sample.",
"Special instructions: no masking on inner bore.",
"Customer will drop off Wednesday morning.",
];
var notes = new List<JobNote>();
for (int i = 0; i < jobs.Count; i++)
{
int hash = (i * 31 + 7) % 10;
if (hash >= 7) continue; // ~30% of jobs get no notes
int noteCount = hash < 3 ? 1 : 2;
var job = jobs[i];
for (int n = 0; n < noteCount; n++)
{
bool isInternal = (i + n) % 4 != 0;
var pool = isInternal ? internalNotes : externalNotes;
notes.Add(new JobNote
{
JobId = job.Id,
Note = pool[(i * 3 + n * 7) % pool.Length],
IsImportant = n == 0 && i % 5 == 0,
IsInternal = isInternal,
CompanyId = company.Id,
CreatedAt = job.CreatedAt.AddDays(1 + n),
});
}
}
if (notes.Count == 0) return 0;
await _context.Set<JobNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Customer Notes
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds account-management notes on the 10 commercial anchor customers and a
/// handful of individual customers so that customer detail pages look like a
/// working CRM rather than a blank slate.
/// </summary>
private async Task<int> SeedCustomerNotesAsync(Company company)
{
var existingCount = await _context.Set<CustomerNote>()
.IgnoreQueryFilters()
.CountAsync(n => n.CompanyId == company.Id && !n.IsDeleted);
if (existingCount > 0) return 0;
// Targeted notes for named commercial anchor accounts
var anchorData = new (string email, string note, bool important)[]
{
("matt@carolinafab.com", "Net 30 terms per contract; invoices go to accounting@carolinafab.com.", true),
("matt@carolinafab.com", "Prefers Gloss Black and Hammertone for structural steel — confirm any color changes.", false),
("ctanner@apexmotorsports.com", "Racing season turnaround required within 5 business days.", true),
("ctanner@apexmotorsports.com", "Color approval required before coating — never assume from previous run.", false),
("jpruitt@triangleoffroad.com", "Skid plates must be coated inside and out — customer checks coverage.", true),
("bsmith@smithwelding.com", "Monthly standing PO — call Bill to confirm quantities before start.", false),
("kmorales@raleigharchitectural.com", "Architectural work: surface finish is critical, QC photos required.", true),
("kmorales@raleigharchitectural.com", "Preferred color: RAL 9005 Jet Black. Has approved alternate: Gloss Black.", false),
("tgreco@eastcoastpw.com", "Net 15 — has paid late twice; flag for follow-up at 10 days.", true),
("dshaw@piedmontmetalworks.com", "Heavy gauge steel — plan for extended sandblast and pre-heat time.", false),
("lpatel@caryindustrial.com", "Parts arrive disassembled; coordinate reassembly quote if needed.", false),
("rblake@durhamtech.com", "University purchase order required on every invoice — ask for PO number.", true),
("mcoleman@wakecountyfleet.gov", "Government contract — tax exempt, Net 60. Do not charge sales tax.", true),
("mcoleman@wakecountyfleet.gov", "Fleet contact: Michelle Coleman. Secondary: Facilities Dept (919) 555-0100.", false),
};
// Generic notes for individual customers (applied deterministically to first N individuals)
string[] genericIndivNotes =
[
"Repeat customer — prefers same color family as previous job.",
"Customer prefers afternoon pickups — call before loading.",
"Cash payment preferred; has paid by check in the past.",
"Sensitive to turnaround time — communicate delays early.",
"Referred by Carolina Fabrication. Treat as preferred.",
];
var notes = new List<CustomerNote>();
var now = DateTime.UtcNow;
// Anchor commercial notes
foreach (var (email, note, important) in anchorData)
{
var customer = await _context.Set<Customer>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Email == email && c.CompanyId == company.Id && !c.IsDeleted);
if (customer == null) continue;
notes.Add(new CustomerNote
{
CustomerId = customer.Id,
Note = note,
IsImportant = important,
CompanyId = company.Id,
CreatedAt = customer.CreatedAt.AddDays(2),
});
}
// Generic notes on the first 8 individual customers (non-commercial)
var individuals = await _context.Set<Customer>()
.IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted && !c.IsCommercial)
.OrderBy(c => c.Id)
.Take(8)
.ToListAsync();
for (int i = 0; i < individuals.Count; i++)
{
notes.Add(new CustomerNote
{
CustomerId = individuals[i].Id,
Note = genericIndivNotes[i % genericIndivNotes.Length],
IsImportant = false,
CompanyId = company.Id,
CreatedAt = individuals[i].CreatedAt.AddDays(3),
});
}
if (notes.Count == 0) return 0;
await _context.Set<CustomerNote>().AddRangeAsync(notes);
await _context.SaveChangesAsync();
return notes.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Rework Records
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds 7&ndash;8 rework records on completed jobs representing a realistic ~5%
/// rework rate. Mix of internal defects (shop fault) and customer-reported
/// warranty claims. About half are resolved; the rest remain open or in-progress.
/// </summary>
private async Task<int> SeedReworkRecordsAsync(Company company)
{
var existingCount = await _context.Set<ReworkRecord>()
.IgnoreQueryFilters()
.CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
if (existingCount > 0) return 0;
// Only completed/delivered jobs qualify for rework records
var terminalCodes = new[] { "COMPLETED", "DELIVERED", "READY_FOR_PICKUP" };
var statusIds = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id && terminalCodes.Contains(s.StatusCode))
.Select(s => s.Id)
.ToListAsync();
var completedJobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted && statusIds.Contains(j.JobStatusId))
.OrderBy(j => j.Id)
.ToListAsync();
if (completedJobs.Count < 5) return 0;
// Deterministic selection: pick jobs at indices 1, 4, 7, 10, 13, 16, 19
var reworkData = new (int jobIdx, ReworkType type, ReworkReason reason,
string description, ReworkDiscoveredBy by, ReworkStatus status,
ReworkResolution? resolution, decimal estCost, decimal actCost,
bool billable, ReworkPricingType? pricing)[]
{
(1, ReworkType.InternalDefect, ReworkReason.AdhesionFailure, "Coating delaminating on three mounting points — surface prep insufficient.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 85m, 95m, false, ReworkPricingType.ShopFault),
(4, ReworkType.InternalDefect, ReworkReason.Contamination, "Fish-eye defects across main panel — contamination during coating.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 60m, 70m, false, ReworkPricingType.ShopFault),
(7, ReworkType.CustomerWarranty, ReworkReason.ColorMismatch, "Customer says delivered color does not match approved sample.", ReworkDiscoveredBy.Customer, ReworkStatus.Resolved, ReworkResolution.CustomerCredited, 120m, 0m, false, ReworkPricingType.ShopFault),
(10, ReworkType.InternalDefect, ReworkReason.RunsSags, "Runs visible on lower edge — caught during QC inspection.", ReworkDiscoveredBy.Internal, ReworkStatus.Resolved, ReworkResolution.RecoatedNoCharge, 45m, 50m, false, ReworkPricingType.ShopFault),
(13, ReworkType.CustomerDamage, ReworkReason.HandlingDamage, "Customer damaged finish during installation — requesting touch-up.", ReworkDiscoveredBy.Customer, ReworkStatus.InProgress, null, 150m, 0m, true, ReworkPricingType.CustomerFull),
(16, ReworkType.InternalDefect, ReworkReason.InsufficientCoverage, "Thin spots on inside radius — insufficient powder in recessed areas.", ReworkDiscoveredBy.Internal, ReworkStatus.Open, null, 70m, 0m, false, ReworkPricingType.ShopFault),
(19, ReworkType.CustomerWarranty, ReworkReason.OvenIssue, "Early cure failure reported 3 months post-delivery — under investigation.", ReworkDiscoveredBy.Customer, ReworkStatus.Open, null, 200m, 0m, false, ReworkPricingType.ShopFault),
};
var records = new List<ReworkRecord>();
foreach (var (jobIdx, type, reason, desc, by, status, resolution, est, act, billable, pricing) in reworkData)
{
if (jobIdx >= completedJobs.Count) continue;
var job = completedJobs[jobIdx];
var discoveredDate = job.CompletedDate ?? job.UpdatedAt ?? job.CreatedAt.AddDays(7);
records.Add(new ReworkRecord
{
JobId = job.Id,
ReworkType = type,
Reason = reason,
DefectDescription = desc,
DiscoveredBy = by,
DiscoveredDate = discoveredDate.AddDays(1),
EstimatedReworkCost = est,
ActualReworkCost = act,
IsBillableToCustomer = billable,
ReworkPricingType = pricing,
Status = status,
Resolution = resolution,
ResolvedDate = status == ReworkStatus.Resolved ? discoveredDate.AddDays(5) : null,
ResolutionNotes = status == ReworkStatus.Resolved ? "Recoated and re-inspected. Customer notified." : null,
CompanyId = company.Id,
CreatedAt = discoveredDate.AddDays(1),
});
}
if (records.Count == 0) return 0;
await _context.Set<ReworkRecord>().AddRangeAsync(records);
await _context.SaveChangesAsync();
return records.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Deposits
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds ~18 deposits spread across commercial and individual jobs.
/// Deposits on jobs that have paid invoices are marked applied; deposits on
/// open or pending jobs remain unapplied, giving the Deposits module a realistic mix.
/// </summary>
private async Task<int> SeedDepositsAsync(Company company)
{
var existingCount = await _context.Set<Deposit>()
.IgnoreQueryFilters()
.CountAsync(d => d.CompanyId == company.Id && !d.IsDeleted);
if (existingCount > 0) return 0;
// Grab jobs with their invoices and customer info
var jobsWithInvoices = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted)
.OrderBy(j => j.Id)
.ToListAsync();
var invoicesByJob = await _context.Set<Invoice>()
.IgnoreQueryFilters()
.Where(i => i.CompanyId == company.Id && !i.IsDeleted && i.JobId.HasValue)
.ToDictionaryAsync(i => i.JobId!.Value, i => i);
// Checking account for deposit account ID
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountNumber == "1000");
if (jobsWithInvoices.Count == 0) return 0;
// Deposit amounts and methods for variety
decimal[] amounts = [150m, 200m, 250m, 300m, 400m, 500m, 750m, 100m, 350m];
var methods = new[] { PaymentMethod.Cash, PaymentMethod.Check, PaymentMethod.CreditDebitCard,
PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.Cash };
var deposits = new List<Deposit>();
int depSeq = 1;
// Seed a deposit for every 3rd job (deterministic ~33% coverage)
foreach (var (job, idx) in jobsWithInvoices.Select((j, i) => (j, i)))
{
if (idx % 3 != 0) continue;
if (deposits.Count >= 20) break;
var receivedDate = job.CreatedAt.AddDays(1);
decimal amount = amounts[idx % amounts.Length];
var method = methods[idx % methods.Length];
string yymm = receivedDate.ToString("yyMM");
string receipt = $"DEP-{yymm}-{depSeq:D4}";
invoicesByJob.TryGetValue(job.Id, out var invoice);
bool isApplied = invoice != null
&& invoice.Status is InvoiceStatus.Paid or InvoiceStatus.PartiallyPaid;
deposits.Add(new Deposit
{
ReceiptNumber = receipt,
CustomerId = job.CustomerId,
JobId = job.Id,
Amount = amount,
PaymentMethod = method,
ReceivedDate = receivedDate,
Reference = method == PaymentMethod.Check ? $"CHK #{4000 + idx}" : null,
Notes = isApplied ? "Applied to invoice on creation." : null,
DepositAccountId = checkingAccount?.Id,
AppliedToInvoiceId = isApplied ? invoice!.Id : null,
AppliedDate = isApplied ? invoice!.InvoiceDate : null,
CompanyId = company.Id,
CreatedAt = receivedDate,
});
depSeq++;
}
if (deposits.Count == 0) return 0;
await _context.Set<Deposit>().AddRangeAsync(deposits);
await _context.SaveChangesAsync();
return deposits.Count;
}
// ═══════════════════════════════════════════════════════════════════════════
// Bank Reconciliations
// ═══════════════════════════════════════════════════════════════════════════
/// <summary>
/// Seeds 3 completed monthly bank reconciliations on the primary checking account
/// covering the 3 full calendar months immediately before the current month.
/// Balances are approximated from the account's opening balance plus a realistic
/// monthly cash-flow trajectory, so they look plausible without needing to match
/// actual transaction totals (which vary per reset).
/// </summary>
private async Task<int> SeedBankReconciliationsAsync(Company company)
{
var existingCount = await _context.Set<BankReconciliation>()
.IgnoreQueryFilters()
.CountAsync(r => r.CompanyId == company.Id && !r.IsDeleted);
if (existingCount > 0) return 0;
var checkingAccount = await _context.Set<Account>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountNumber == "1000");
if (checkingAccount == null) return 0;
var now = DateTime.UtcNow;
var reconciliations = new List<BankReconciliation>();
// Build 3 months of completed reconciliations ending the last day of each month.
// Balances step up ~$4-6k per month to reflect a moderately profitable shop.
decimal[] endingBalances = [62_400m, 67_800m, 73_250m];
for (int i = 3; i >= 1; i--)
{
var statementMonth = new DateTime(now.Year, now.Month, 1).AddMonths(-i);
var statementDate = new DateTime(statementMonth.Year, statementMonth.Month,
DateTime.DaysInMonth(statementMonth.Year, statementMonth.Month),
23, 59, 59, DateTimeKind.Utc);
int balIdx = 3 - i; // 0,1,2
decimal ending = endingBalances[balIdx];
decimal beginning = balIdx == 0 ? ending - 5_200m : endingBalances[balIdx - 1];
reconciliations.Add(new BankReconciliation
{
AccountId = checkingAccount.Id,
StatementDate = statementDate,
BeginningBalance = beginning,
EndingBalance = ending,
Status = BankReconciliationStatus.Completed,
CompletedAt = statementDate.AddDays(3),
CompletedBy = "demo@powdercoatinglogix.com",
Notes = $"Monthly close — {statementMonth:MMMM yyyy}. All transactions cleared.",
CompanyId = company.Id,
CreatedAt = statementDate.AddDays(3),
});
}
await _context.Set<BankReconciliation>().AddRangeAsync(reconciliations);
await _context.SaveChangesAsync();
return reconciliations.Count;
}
}
@@ -54,17 +54,25 @@ public partial class SeedDataService
if (items.Count == 0) return 0; if (items.Count == 0) return 0;
// Load completed/delivered jobs to generate usage transactions against // Two-query approach: resolve status IDs first to avoid Include() navigation
var completedJobs = await _context.Set<Job>() // returning null when global query filters interact with IgnoreQueryFilters().
var completedStatusIds = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted .Where(s => s.CompanyId == company.Id
&& (j.JobStatus.StatusCode == "COMPLETED" && new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }.Contains(s.StatusCode))
|| j.JobStatus.StatusCode == "READY_FOR_PICKUP" .Select(s => s.Id)
|| j.JobStatus.StatusCode == "DELIVERED"))
.Include(j => j.JobStatus)
.OrderBy(j => j.Id)
.ToListAsync(); .ToListAsync();
var completedJobs = completedStatusIds.Count == 0
? new List<Job>()
: await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& completedStatusIds.Contains(j.JobStatusId))
.Include(j => j.JobItems)
.OrderBy(j => j.Id)
.ToListAsync();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var seeded = 0; var seeded = 0;
var txns = new List<InventoryTransaction>(); var txns = new List<InventoryTransaction>();
@@ -90,14 +98,18 @@ public partial class SeedDataService
}); });
} }
// ── Purchase transactions — 3 months of restocks ────────────────────── // ── Purchase transactions — 12 months of monthly restocks ────────────
// Simulate monthly powder purchases for top items var powderItems = items.Take(8).ToList();
var powderItems = items.Take(8).ToList(); // focus on powder coat items // Quantities vary slightly month to month to give the inventory chart a natural shape
var purchaseOffsets = new (int daysAgo, decimal mult)[]
foreach (var (offset, qtyMult) in new (int daysAgo, decimal mult)[] {
(85, 1.2m), (55, 1.0m), (25, 0.9m) })
{ {
foreach (var item in powderItems.Take(4)) // 4 items per purchase cycle (365, 0.80m), (335, 0.85m), (305, 0.90m), (275, 0.95m),
(245, 1.00m), (215, 1.05m), (185, 1.10m), (155, 1.10m),
(125, 1.00m), ( 95, 1.10m), ( 65, 1.00m), ( 35, 0.95m)
};
foreach (var (offset, qtyMult) in purchaseOffsets)
{
foreach (var item in powderItems.Take(4))
{ {
var qty = Math.Round(25m * qtyMult, 0); var qty = Math.Round(25m * qtyMult, 0);
txns.Add(new InventoryTransaction txns.Add(new InventoryTransaction
@@ -144,9 +156,10 @@ public partial class SeedDataService
foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i))) foreach (var (job, idx) in completedJobs.Select((j, i) => (j, i)))
{ {
// Completed date spread: most within the last 60 days // Use the job's actual completion date so powder usage history spans the same
var daysAgo = 10 + (idx % 55); // 12-month window as jobs, giving the Powder Usage report non-trivial data in
var usageDate = now.AddDays(-daysAgo); // every month rather than clustering everything in the last 60 days.
var usageDate = (job.CompletedDate ?? job.ScheduledDate ?? now.AddDays(-30)).Date;
// Pick a color-matched powder item (or rotate) // Pick a color-matched powder item (or rotate)
var firstItem = job.JobItems?.FirstOrDefault(); var firstItem = job.JobItems?.FirstOrDefault();
@@ -183,6 +183,23 @@ public partial class SeedDataService
} }
} }
// ── Months 12 through 4: 8 paid invoices per month ────────────────
// 8 × avg ~$650 = ~$5,200/month collected revenue, which exceeds the seeded
// ~$4,200/month in operating expenses, so the P&L chart shows a consistent profit.
// Payment methods and tax rates alternate for variety in the ledger.
var histMethods = new[] { PaymentMethod.BankTransferACH, PaymentMethod.Check, PaymentMethod.CreditDebitCard, PaymentMethod.Cash };
for (int monthBack = 12; monthBack >= 4; monthBack--)
{
for (int inv = 0; inv < 8; inv++)
{
var daysAgo = monthBack * 30 + 25 - (inv * 3);
var taxPct = (monthBack + inv) % 2 == 0 ? 7.5m : 0m;
var method = histMethods[(monthBack * 8 + inv) % histMethods.Length];
var chkRef = method == PaymentMethod.Check ? $"CHK-{9000 + monthBack * 8 + inv:D4}" : null;
await Inv(InvoiceStatus.Paid, daysAgo, 30, taxPct, "Net 30", "Thank you for your business!", method, chkRef);
}
}
// ── Month 3 (6 paid) ───────────────────────────────────────────────── // ── Month 3 (6 paid) ─────────────────────────────────────────────────
await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH); await Inv(InvoiceStatus.Paid, 88, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 84, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1041"); await Inv(InvoiceStatus.Paid, 84, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1041");
@@ -201,24 +218,35 @@ public partial class SeedDataService
await Inv(InvoiceStatus.PartiallyPaid, 40, 30, 0m, "Net 30", "50% deposit received — balance due.", PaymentMethod.Check, "CHK-1053"); await Inv(InvoiceStatus.PartiallyPaid, 40, 30, 0m, "Net 30", "50% deposit received — balance due.", PaymentMethod.Check, "CHK-1053");
await Inv(InvoiceStatus.PartiallyPaid, 37, 30, 7.5m, "Net 30", "Deposit on file — balance due on pickup.", PaymentMethod.BankTransferACH); await Inv(InvoiceStatus.PartiallyPaid, 37, 30, 7.5m, "Net 30", "Deposit on file — balance due on pickup.", PaymentMethod.BankTransferACH);
// ── Month 1 (5 paid + 2 partial + 2 sent) ─────────────────────────── // ── Month 1 (7 paid + 2 partial + 2 sent) ───────────────────────────
await Inv(InvoiceStatus.Paid, 32, 30, 0m, "Net 30", "Thank you!", PaymentMethod.BankTransferACH); // 7 paid × avg ~$650 + 2 partial × 50% × avg ~$650 ≈ $5,200 collected — above expenses.
await Inv(InvoiceStatus.Paid, 28, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1056"); await Inv(InvoiceStatus.Paid, 35, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 24, 14, 0m, "Net 14", "Thank you!", PaymentMethod.Cash); await Inv(InvoiceStatus.Paid, 32, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.Check, "CHK-1054");
await Inv(InvoiceStatus.Paid, 20, 30, 7.5m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH); await Inv(InvoiceStatus.Paid, 28, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard); await Inv(InvoiceStatus.Paid, 24, 14, 7.5m, "Net 14", "Thank you!", PaymentMethod.Cash);
await Inv(InvoiceStatus.Paid, 20, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Paid, 18, 30, 7.5m, "Net 30", "Thank you!", PaymentMethod.CreditDebitCard);
await Inv(InvoiceStatus.Paid, 16, 30, 0m, "Net 30", "Thank you for your business!", PaymentMethod.Check, "CHK-1058");
await Inv(InvoiceStatus.PartiallyPaid, 14, 30, 7.5m, "Net 30", "50% deposit received.", PaymentMethod.Check, "CHK-1060"); await Inv(InvoiceStatus.PartiallyPaid, 14, 30, 7.5m, "Net 30", "50% deposit received.", PaymentMethod.Check, "CHK-1060");
await Inv(InvoiceStatus.PartiallyPaid, 11, 30, 0m, "Net 30", "50% deposit — balance due on completion.", PaymentMethod.BankTransferACH); await Inv(InvoiceStatus.PartiallyPaid, 11, 30, 0m, "Net 30", "50% deposit — balance due on completion.", PaymentMethod.BankTransferACH);
await Inv(InvoiceStatus.Sent, 9, 30, 7.5m, "Net 30", "Payment due within 30 days."); await Inv(InvoiceStatus.Sent, 9, 30, 7.5m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Sent, 6, 30, 0m, "Net 30", "Payment due within 30 days."); await Inv(InvoiceStatus.Sent, 6, 30, 0m, "Net 30", "Payment due within 30 days.");
// ── Current month (1 overdue + 2 sent + 1 draft) ───────────────────── // ── Current month (1 overdue + 2 sent + 1 draft) ─────────────────────
// Overdue: created 35 days ago on Net 14 terms → 21 days past due // Overdue: created 35 days ago on Net 14 terms → 21 days past due (130 bucket)
await Inv(InvoiceStatus.Sent, 35, 14, 7.5m, "Net 14", "PAST DUE — please remit payment immediately."); await Inv(InvoiceStatus.Sent, 35, 14, 7.5m, "Net 14", "PAST DUE — please remit payment immediately.");
await Inv(InvoiceStatus.Sent, 4, 30, 0m, "Net 30", "Payment due within 30 days."); await Inv(InvoiceStatus.Sent, 4, 30, 0m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Sent, 2, 30, 7.5m, "Net 30", "Payment due within 30 days."); await Inv(InvoiceStatus.Sent, 2, 30, 7.5m, "Net 30", "Payment due within 30 days.");
await Inv(InvoiceStatus.Draft, 1, 30, 0m, "Net 30", null); await Inv(InvoiceStatus.Draft, 1, 30, 0m, "Net 30", null);
// ── AR Aging demo invoices — populate all four overdue buckets ────────
// 3160 day bucket: issued 55 days ago, Net 10 → 45 days past due
await Inv(InvoiceStatus.Sent, 55, 10, 0m, "Net 10", "PAST DUE 45 days — second notice sent.");
// 6190 day bucket: issued 80 days ago, Net 10 → 70 days past due
await Inv(InvoiceStatus.Sent, 80, 10, 7.5m, "Net 10", "PAST DUE 70 days — final notice. Collections pending.");
// 90+ day bucket: issued 120 days ago, Net 14 → 106 days past due
await Inv(InvoiceStatus.PartiallyPaid, 120, 14, 0m, "Net 14", "PAST DUE 106 days — partial payment received, balance outstanding.");
return seeded; return seeded;
} }
} }
@@ -6,157 +6,122 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds a plausible status-transition history for every job belonging to the company, /// Seeds <see cref="JobStatusHistory"/> transition records for every completed, delivered,
/// reconstructing the sequence of transitions a job must have passed through to reach /// or ready-for-pickup job so the Job Cycle Time report can calculate time-per-stage data.
/// its current status.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Idempotency: returns 0 immediately if any non-deleted history rows already exist for /// For each qualifying job the seeder builds a realistic stage sequence:
/// this company. /// PENDING &rarr; IN_PREPARATION &rarr; (SANDBLASTING if any item requires it)
/// &rarr; (MASKING_TAPING if any item requires it) &rarr; CLEANING &rarr; IN_OVEN
/// &rarr; COATING &rarr; CURING &rarr; QUALITY_CHECK &rarr; [terminal status].
/// </para> /// </para>
/// <para> /// <para>
/// The method does not record arbitrary transitions — it follows the canonical 14-step /// 85&thinsp;% of the job's total cycle time (CreatedAt &rarr; CompletedDate) is
/// pipeline array (<c>PENDING → QUOTED → APPROVED → … → DELIVERED</c>) and generates /// distributed across work stages using fixed per-stage weights that reflect realistic
/// one <see cref="JobStatusHistory"/> row per transition step, from <c>PENDING</c> up to /// relative durations (e.g. SANDBLASTING &gt; CLEANING). The remaining 15&thinsp;%
/// and including the job's current status. /// is left as residual "terminal status" time, which surfaces correctly in the report's
/// last-entry formula <c>(job.CompletedDate &minus; last.ChangedDate)</c>.
/// </para> /// </para>
/// <para> /// <para>
/// Terminal side-branch statuses are handled explicitly: /// Idempotency: returns 0 immediately if any history records already exist for
/// <list type="bullet"> /// this company, matching the pattern used by all other partial seeders.
/// <item><c>ON_HOLD</c> — assumed to have reached <c>QUALITY_CHECK</c> before pausing.</item>
/// <item><c>CANCELLED</c> — assumed to have been cancelled from <c>IN_PREPARATION</c>.</item>
/// </list>
/// </para>
/// <para>
/// Transition timestamps are spread ~6 hours apart starting from <c>job.CreatedAt</c>.
/// This is an approximation chosen for demo realism; actual production transitions record
/// the wall-clock time at which a user changes the status. A safety clamp prevents any
/// generated timestamp from exceeding <c>DateTime.UtcNow</c>.
/// </para>
/// <para>
/// All history rows are batched into a single <c>AddRangeAsync / SaveChangesAsync</c>
/// call for performance, since the total count can be several hundred rows (50 jobs × up
/// to 14 transitions each).
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed job status history for.</param> /// <param name="company">The tenant company to seed history for.</param>
/// <returns>Total number of history rows inserted, or 0 if already seeded or no jobs exist.</returns> /// <returns>Number of history records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedJobStatusHistoryAsync(Company company) private async Task<int> SeedJobStatusHistoryAsync(Company company)
{ {
var existingCount = await _context.Set<JobStatusHistory>() var existingCount = await _context.Set<JobStatusHistory>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted); .CountAsync(h => h.CompanyId == company.Id && !h.IsDeleted);
if (existingCount > 0) if (existingCount > 0) return 0;
return 0;
// Load all job status lookups into a code → id map // Only completed-terminal jobs have a meaningful CompletedDate to calculate cycle time.
var statusMap = await _context.Set<JobStatusLookup>() // CANCELLED is excluded — the report cares only about successfully finished work.
.IgnoreQueryFilters() var terminalCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
.Where(s => s.CompanyId == company.Id)
.ToDictionaryAsync(s => s.StatusCode, s => s.Id);
// Load jobs with their current status
var jobs = await _context.Set<Job>() var jobs = await _context.Set<Job>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Include(j => j.JobItems)
.Include(j => j.JobStatus) .Include(j => j.JobStatus)
.Where(j => j.CompanyId == company.Id && !j.IsDeleted) .Where(j => j.CompanyId == company.Id && !j.IsDeleted && j.CompletedDate.HasValue)
.OrderBy(j => j.Id)
.ToListAsync(); .ToListAsync();
if (jobs.Count == 0 || statusMap.Count == 0) jobs = jobs.Where(j => terminalCodes.Contains(j.JobStatus.StatusCode)).ToList();
return 0; if (jobs.Count == 0) return 0;
// Ordered pipeline — each status code in the order a job advances through it. var statuses = await _context.Set<JobStatusLookup>()
// ON_HOLD and CANCELLED are terminal side-branches handled separately. .IgnoreQueryFilters()
var pipeline = new[] .Where(s => s.CompanyId == company.Id)
{ .ToDictionaryAsync(s => s.StatusCode, s => s);
"PENDING", "QUOTED", "APPROVED", "IN_PREPARATION",
"SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN",
"COATING", "CURING", "QUALITY_CHECK",
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
};
var pipelineIndex = pipeline var records = new List<JobStatusHistory>();
.Select((code, idx) => (code, idx))
.ToDictionary(t => t.code, t => t.idx);
var history = new List<JobStatusHistory>();
var now = DateTime.UtcNow;
foreach (var job in jobs) foreach (var job in jobs)
{ {
var currentCode = job.JobStatus.StatusCode; var totalSeconds = (job.CompletedDate!.Value - job.CreatedAt).TotalSeconds;
if (totalSeconds < 60) continue; // skip malformed dates
// Determine the sequence of transitions that happened to reach current state. var needsSand = job.JobItems.Any(i => i.RequiresSandblasting);
// For ON_HOLD: assume it came from QUALITY_CHECK before going on hold. var needsMask = job.JobItems.Any(i => i.RequiresMasking);
// For CANCELLED: assume cancelled from APPROVED or IN_PREPARATION.
string[] codesTraversed;
if (currentCode == "ON_HOLD") // Build ordered stage list; the last element is the terminal "to" status only —
// it never appears as a "from" and is not assigned a work weight.
var stages = new List<string> { "PENDING", "IN_PREPARATION" };
if (needsSand) stages.Add("SANDBLASTING");
if (needsMask) stages.Add("MASKING_TAPING");
stages.AddRange(new[] { "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" });
stages.Add(job.JobStatus.StatusCode);
// Time weight for each "from" status — reflects typical relative hours in that stage.
static double Weight(string code) => code switch
{ {
// Traversed up to QUALITY_CHECK then went ON_HOLD "PENDING" => 2.0, // intake / scheduling buffer
codesTraversed = [.. pipeline.Take(pipelineIndex["QUALITY_CHECK"] + 1), "ON_HOLD"]; "IN_PREPARATION" => 1.5, // disassembly, hang, pre-inspect
} "SANDBLASTING" => 2.0, // media blast + blow-off
else if (currentCode == "CANCELLED") "MASKING_TAPING" => 1.0, // tape & plug work
"CLEANING" => 0.5, // chemical wash + dry
"IN_OVEN" => 1.5, // pre-heat before coating
"COATING" => 1.5, // powder application
"CURING" => 1.0, // oven cure cycle
"QUALITY_CHECK" => 0.5, // inspection & touch-up
_ => 0.5,
};
// Work stages are all entries except the terminal "to" at the end.
int n = stages.Count;
var workWeights = stages.Take(n - 1).Select(Weight).ToList();
double totalWeight = workWeights.Sum();
// 85% of cycle time covers the work stages; 15% becomes terminal-status residual
// so (job.CompletedDate last.ChangedDate) produces a non-zero, plausible value.
double workSeconds = totalSeconds * 0.85;
var currentDate = job.CreatedAt;
for (int i = 0; i < n - 1; i++)
{ {
// Cancelled from IN_PREPARATION if (!statuses.TryGetValue(stages[i], out var fromLookup)) continue;
codesTraversed = ["PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "CANCELLED"]; if (!statuses.TryGetValue(stages[i + 1], out var toLookup)) continue;
}
else if (pipelineIndex.TryGetValue(currentCode, out int curIdx))
{
// Normal pipeline job — traversed from PENDING up to current status
codesTraversed = pipeline.Take(curIdx + 1).ToArray();
}
else
{
// Unknown status — just record a single PENDING → currentCode entry
codesTraversed = ["PENDING", currentCode];
}
// Spread transition dates backwards from job.CreatedAt. currentDate = currentDate.AddSeconds(workSeconds * workWeights[i] / totalWeight);
// Each step took roughly 48 hours, so transitions are spaced a few hours apart.
// Jobs further along in the pipeline have older start dates.
var stepCount = codesTraversed.Length - 1; // number of transitions
if (stepCount <= 0) continue;
// Job was created at job.CreatedAt; each transition is spaced ~6h apart records.Add(new JobStatusHistory
// so the first transition (PENDING→QUOTED) happened ~6h after creation, etc.
for (int t = 0; t < stepCount; t++)
{
var fromCode = codesTraversed[t];
var toCode = codesTraversed[t + 1];
if (!statusMap.TryGetValue(fromCode, out int fromId)) continue;
if (!statusMap.TryGetValue(toCode, out int toId)) continue;
// Spread: first transitions happened closer to job creation,
// later ones closer to now. Add a few hours per step.
var hoursOffset = (t + 1) * 6;
var changedDate = job.CreatedAt.AddHours(hoursOffset);
// Don't let transitions exceed "now"
if (changedDate > now) changedDate = now.AddMinutes(-(stepCount - t) * 10);
history.Add(new JobStatusHistory
{ {
JobId = job.Id, JobId = job.Id,
FromStatusId = fromId, FromStatusId = fromLookup.Id,
ToStatusId = toId, ToStatusId = toLookup.Id,
ChangedDate = changedDate, ChangedDate = currentDate,
Notes = null,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = changedDate CreatedAt = currentDate,
}); });
} }
} }
if (history.Count == 0) return 0; if (records.Count == 0) return 0;
await _context.Set<JobStatusHistory>().AddRangeAsync(history); await _context.Set<JobStatusHistory>().AddRangeAsync(records);
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
return records.Count;
return history.Count;
} }
} }
@@ -7,41 +7,30 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds 50 powder coating jobs that collectively demonstrate all 16 job statuses, /// Seeds 50 powder coating jobs distributed across all 16 statuses with a realistic
/// realistic date progressions, varied priorities, and quote linkage for the first 25 jobs. /// weighted distribution (Delivered most common, exactly one Cancelled, one OnHold)
/// and a shuffled visit order so jobs from different customers are interleaved naturally
/// rather than appearing as per-customer blocks.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Idempotency: returns 0 immediately if any non-deleted jobs already exist for this company. /// Per-customer job counts and price ranges are defined by <c>CustomerProfile(ci)</c>
/// where <c>ci</c> is the customer's position in the Id-ascending list seeded by
/// <c>SeedCustomersAsync</c> (0 = Carolina Fabrication, the largest account).
/// </para> /// </para>
/// <para> /// <para>
/// The method depends on job-status and job-priority lookup rows (populated earlier in the /// A shuffled <c>visitSchedule</c> (fixed seed 42) drives the outer loop so that
/// seed sequence), and on at least one customer record. It returns 0 if any of these /// Carolina Fabrication, Apex Motorsports, and individual customers appear
/// dependencies are missing so the overall seed degrades gracefully. /// interleaved in creation-date order rather than in consecutive customer blocks.
/// </para> /// </para>
/// <para> /// <para>
/// Job numbers follow the production format <c>JOB-YYMM-####</c>. The seeder scans /// Status pool (fixed seed 99): Delivered &times;10, Completed &times;8,
/// existing numbers with the current month prefix and starts its sequence above the current /// ReadyForPickup &times;5, then decreasing counts for in-progress stages, with
/// maximum so demo jobs never collide with real jobs created in the same calendar month. /// exactly one Cancelled and one OnHold.
/// </para> /// Priority pool (fixed seed 77): Normal 76&thinsp;%, High 12&thinsp;%, Urgent 8&thinsp;%,
/// <para> /// Rush 4&thinsp;% — rush is genuinely rare.
/// The first 25 jobs are linked to approved quotes (loaded from the previously seeded
/// quotes). When a match is found the job inherits the quote's customer, description,
/// quoted price, and customer PO — matching the production quote-to-job conversion path.
/// </para>
/// <para>
/// Date logic groups jobs into three buckets: early-stage (future scheduled date),
/// in-progress (past start date, no completion), and completed/terminal (both started
/// and completed dates in the past). This ensures the dashboard pipeline and calendar
/// views display a realistic spread rather than all jobs sharing the same date.
/// </para>
/// <para>
/// The <c>IgnoreQueryFilters()</c> call on the existence check ensures that soft-deleted
/// leftover jobs from a previous seed run are detected and do not cause duplicate inserts.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed jobs for.</param>
/// <returns>Number of jobs inserted, or 0 if already seeded or dependencies are missing.</returns>
private async Task<int> SeedJobsAsync(Company company) private async Task<int> SeedJobsAsync(Company company)
{ {
var existingCount = await _context.Set<Job>() var existingCount = await _context.Set<Job>()
@@ -73,11 +62,11 @@ public partial class SeedDataService
if (customers.Count == 0) if (customers.Count == 0)
return 0; return 0;
// Grab approved quotes to link to jobs
var approvedQuotes = await _context.Set<Quote>() var approvedQuotes = await _context.Set<Quote>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED") .Where(q => q.CompanyId == company.Id && q.QuoteStatus.StatusCode == "APPROVED")
.OrderBy(q => q.Id) .OrderBy(q => q.CustomerId)
.ThenBy(q => q.Id)
.ToListAsync(); .ToListAsync();
var shopUsers = await _context.Set<ApplicationUser>() var shopUsers = await _context.Set<ApplicationUser>()
@@ -86,7 +75,6 @@ public partial class SeedDataService
.ToListAsync(); .ToListAsync();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var prefix = $"JOB-{now:yy}{now.Month:D2}-"; var prefix = $"JOB-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Job>() var existing = await _context.Set<Job>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
@@ -98,129 +86,240 @@ public partial class SeedDataService
if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x; if (n.Length >= 13 && int.TryParse(n.Substring(9, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1; var seq = maxNum + 1;
// ── Status plan (50 jobs, covering all 16 statuses) ────────────────── // ── Per-customer profile: (jobCount, minJobValue, maxJobValue) ─────────
// Active pipeline: PENDING(4) QUOTED(3) APPROVED(4) IN_PREPARATION(4) // Indices match the customer insertion order from SeedCustomersAsync (ascending Id):
// SANDBLASTING(4) MASKING_TAPING(3) CLEANING(3) IN_OVEN(3) // 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
// COATING(4) CURING(3) QUALITY_CHECK(3) COMPLETED(5) // 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
// READY_FOR_PICKUP(4) DELIVERED(3) ON_HOLD(2) CANCELLED(2) // 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
// // 1026 = individual residential customers
// Maps job index to a status code, distributing all 16 statuses across 50 jobs. static (int count, decimal minVal, decimal maxVal) CustomerProfile(int ci) => ci switch
// ON_HOLD and CANCELLED are placed last (indices 4849) because they are terminal
// side-branches that affect date logic and status history traversal differently.
static string StatusFor(int i) => i switch
{ {
< 4 => "PENDING", 0 => (7, 800m, 2500m), // Carolina Fabrication — largest account
< 7 => "QUOTED", 1 => (6, 400m, 1500m), // Apex Motorsports
< 11 => "APPROVED", 2 => (5, 350m, 1200m), // Triangle Offroad
< 15 => "IN_PREPARATION", 3 => (4, 250m, 800m), // Smith Welding
< 19 => "SANDBLASTING", 4 => (4, 300m, 900m), // Raleigh Architectural Metals
< 22 => "MASKING_TAPING", 5 => (3, 200m, 600m), // East Coast Powderworks
< 25 => "CLEANING", 6 => (3, 150m, 450m), // Piedmont Metal Works
< 28 => "IN_OVEN", 7 => (2, 200m, 500m), // Cary Industrial Solutions
< 32 => "COATING", 8 => (2, 350m, 900m), // Durham Tech Equipment
< 35 => "CURING", 9 => (3, 400m, 1500m), // Wake County Fleet Services
< 38 => "QUALITY_CHECK", 10 => (2, 75m, 250m), // John Davis
< 43 => "COMPLETED", 11 => (1, 150m, 350m), // Sarah Jenkins
< 47 => "READY_FOR_PICKUP", 12 => (1, 200m, 400m), // Mike Thompson
< 48 => "DELIVERED", 13 => (2, 100m, 300m), // Robert Miller
< 49 => "ON_HOLD", 14 => (0, 0m, 0m), // Jennifer Clark — prospect only
_ => "CANCELLED" 15 => (1, 100m, 250m), // David Wilson
16 => (0, 0m, 0m), // Lisa Anderson — prospect only
17 => (1, 150m, 300m), // Thomas Harris
18 => (0, 0m, 0m), // Karen White — no jobs yet
19 => (1, 250m, 500m), // James Taylor
20 => (0, 0m, 0m), // Michelle Brown — no jobs yet
21 => (1, 100m, 250m), // Chris Lee
22 => (0, 0m, 0m), // Amanda Garcia — no jobs yet
23 => (1, 150m, 350m), // Kevin Martinez
24 => (0, 0m, 0m), // Nancy Rodriguez — no jobs yet
25 => (0, 0m, 0m), // Brian Hall — no jobs yet
_ => (0, 0m, 0m), // Patricia Young — no jobs yet
}; };
// Maps job index modulo 10 to a priority code. RUSH and URGENT are intentionally // ── Status pool: realistic shop distribution (total = 50) ──────────────
// over-represented (4 of 10) relative to production averages so the priority colour // Delivered and Completed dominate; exactly one Cancelled and one OnHold.
// badges and rush-fee logic are clearly visible in demo data. var statusPool = new List<string>();
static string PriorityFor(int i) => (i % 10) switch foreach (var (code, count) in new (string Code, int Count)[]
{ {
0 => "RUSH", ("DELIVERED", 10),
1 => "RUSH", ("COMPLETED", 8),
2 => "URGENT", ("READY_FOR_PICKUP", 5),
3 => "URGENT", ("IN_PREPARATION", 4),
4 => "HIGH", ("SANDBLASTING", 4),
5 => "HIGH", ("COATING", 3),
6 => "HIGH", ("QUALITY_CHECK", 3),
_ => "NORMAL" ("CURING", 3),
}; ("MASKING_TAPING", 2),
("IN_OVEN", 2),
("CLEANING", 1),
("PENDING", 1),
("QUOTED", 1),
("APPROVED", 1),
("ON_HOLD", 1),
("CANCELLED", 1),
})
{
for (int k = 0; k < count; k++) statusPool.Add(code);
}
// Fisher-Yates shuffle with a fixed seed so resets produce the same distribution
var statusRng = new Random(99);
for (int k = statusPool.Count - 1; k > 0; k--)
{
var swap = statusRng.Next(k + 1);
(statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]);
}
// Returns description, finish color, prep flags, and estimated minutes for a job item. // ── Priority pool: realistic distribution (total = 50) ─────────────────
// Indexed by (i * 3 + j) % 15 so that item variety cycles independently of the job index, // Rush jobs are genuinely rare; most work is Normal priority.
// preventing every job from having the same first item. var priorityPool = new List<string>();
foreach (var (code, count) in new (string Code, int Count)[]
{
("NORMAL", 38),
("HIGH", 6),
("URGENT", 4),
("RUSH", 2),
})
{
for (int k = 0; k < count; k++) priorityPool.Add(code);
}
var priorityRng = new Random(77);
for (int k = priorityPool.Count - 1; k > 0; k--)
{
var swap = priorityRng.Next(k + 1);
(priorityPool[k], priorityPool[swap]) = (priorityPool[swap], priorityPool[k]);
}
// ── Customer visit schedule: interleave commercial (ci 09) and individual (ci 10+) ──
// A plain Fisher-Yates on the full list clusters commercial entries because they
// outnumber individual ones 4:1; splitting into two pools and distributing
// individual jobs evenly throughout ensures the two types never appear in blocks.
var commercialVisits = new List<int>();
var individualVisits = new List<int>();
for (int ci = 0; ci < customers.Count; ci++)
{
var (numJobs, _, _) = CustomerProfile(ci);
for (int j = 0; j < numJobs; j++)
(ci < 10 ? commercialVisits : individualVisits).Add(ci);
}
var rngC = new Random(42);
for (int k = commercialVisits.Count - 1; k > 0; k--)
{
var swap = rngC.Next(k + 1);
(commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]);
}
var rngI = new Random(17);
for (int k = individualVisits.Count - 1; k > 0; k--)
{
var swap = rngI.Next(k + 1);
(individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]);
}
// Distribute individual visits at evenly-spaced positions throughout the commercial list
var visitSchedule = new List<int>(commercialVisits.Count + individualVisits.Count);
double indStride = individualVisits.Count > 0
? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0)
: double.MaxValue;
int indInsertIdx = 0;
for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++)
{
while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1)
visitSchedule.Add(individualVisits[indInsertIdx++]);
visitSchedule.Add(commercialVisits[comIdx]);
}
while (indInsertIdx < individualVisits.Count)
visitSchedule.Add(individualVisits[indInsertIdx++]);
// Job item descriptions and specs — 15-item pool cycling via (visitIdx*3 + itemIdx) % 15.
static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) => static (string desc, string color, bool sand, bool mask, int mins) ItemSpec(int i, int j) =>
((i * 3 + j) % 15) switch ((i * 3 + j) % 15) switch
{ {
0 => ("18\" Aluminum Wheels — Matte Black", "Matte Black", true, false, 45), 0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45),
1 => ("17\" Steel Wheels — Gloss White", "Gloss White", false, false, 30), 1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30),
2 => ("Valve Covers — Wrinkle Red", "Wrinkle Red", true, true, 40), 2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60),
3 => ("Motorcycle Frame — Flat Black", "Flat Black", true, false, 90), 3 => ("Motorcycle Frame", "Matte Black", true, false, 90),
4 => ("Steel Shelving Units", "Textured Gray", true, false, 55), 4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35), 5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35),
6 => ("Aluminum Window Frames", "Satin Bronze", false, true, 50), 6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50),
7 => ("Steel Handrail — 40 ft run", "Gloss Black", true, false, 120), 7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120),
8 => ("Wrought Iron Gate", "Hammered Black", true, false, 180), 8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180),
9 => ("Brake Calipers — Gloss Yellow", "Gloss Yellow", false, true, 35), 9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60), 10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60),
11 => ("Bicycle Frame — Candy Blue", "Candy Blue", true, true, 60), 11 => ("Bicycle Frame", "Candy Red", true, true, 60),
12 => ("Compressor Tank", "Safety Orange", true, false, 45), 12 => ("Compressor Tank", "Safety Orange", true, false, 45),
13 => ("Patio Furniture Set", "Textured Beige", false, false, 50), 13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50),
_ => ("Custom Steel Parts — Batch", "Matte Gray", true, false, 40) _ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40)
}; };
var jobs = new List<Job>(); var jobs = new List<Job>();
var quoteIdx = 0; var quoteIdx = 0;
var jobsByCustomer = new int[customers.Count]; // within-customer job counter per ci
var jobIdx = 0; // global counter for misc modulos
var inProgressCount = 0; // caps "Carried Over" card to 2 jobs
var completedJobCount = 0; // drives linear date spread over 12 months
for (int i = 0; i < 50; i++) for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++, jobIdx++, seq++)
{ {
var statusCode = StatusFor(i); var ci = visitSchedule[visitIdx];
var priorityCode = PriorityFor(i); var customer = customers[ci];
var customer = customers[i % customers.Count]; var j = jobsByCustomer[ci]++; // within-customer job index
var (_, minVal, maxVal) = CustomerProfile(ci);
// Link an approved quote to the first 25 in-progress/active jobs var statusCode = statusPool[visitIdx];
var priorityCode = priorityPool[visitIdx];
// Try to link the first available approved quote for this customer
Quote? linkedQuote = null; Quote? linkedQuote = null;
if (i < 25 && quoteIdx < approvedQuotes.Count) for (int qi = quoteIdx; qi < approvedQuotes.Count; qi++)
{ {
// Only link if the quote's customer matches OR if customers align by index if (approvedQuotes[qi].CustomerId == customer.Id)
linkedQuote = approvedQuotes[quoteIdx++]; {
customer = customers.FirstOrDefault(c => c.Id == linkedQuote.CustomerId) ?? customer; linkedQuote = approvedQuotes[qi];
quoteIdx = qi + 1;
break;
}
// Every 4th job, forcibly consume the next available approved quote
if (quoteIdx % 4 == 0 && qi == quoteIdx)
{
linkedQuote = approvedQuotes[qi];
quoteIdx++;
break;
}
} }
// Date logic — creation spread from -21 days to today // Date logic: completed jobs furthest back, ready-for-pickup recent past,
// Scheduled: future for early statuses, past for completed ones // in-progress spread forward, pending/quoted in the future.
var isCompleted = statusCode is "COMPLETED" or "READY_FOR_PICKUP" or "DELIVERED" or "CANCELLED"; var isCompleted = statusCode is "COMPLETED" or "DELIVERED" or "CANCELLED";
var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING" var isReadyForPickup = statusCode == "READY_FOR_PICKUP";
or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK"; var isInProgress = statusCode is "IN_PREPARATION" or "SANDBLASTING" or "MASKING_TAPING"
var isEarly = statusCode is "PENDING" or "QUOTED" or "APPROVED"; or "CLEANING" or "IN_OVEN" or "COATING" or "CURING" or "QUALITY_CHECK";
int daysAgo = isCompleted ? 14 + (i % 7) if (isInProgress) inProgressCount++;
: isInProgress ? 5 + (i % 7) if (isCompleted) completedJobCount++;
: 0 + (i % 5);
var createdDate = now.AddDays(-daysAgo);
var scheduledDate = isCompleted ? createdDate.AddDays(2)
: isInProgress ? now.AddDays(-(i % 3))
: now.AddDays(2 + (i % 10));
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
var dueDate = scheduledDate.AddDays(rushDays);
var startedDate = (!isEarly) ? scheduledDate : (DateTime?)null;
var completedDate = isCompleted ? scheduledDate.AddDays(1) : (DateTime?)null;
var assignedUserId = shopUsers.Count > 0 ? shopUsers[i % shopUsers.Count].Id : null; // Completed jobs spread linearly ~112 months back for chart coverage.
// Ready-for-pickup: job finished recently, just waiting on customer.
// In-progress: first 3 are genuinely past-due ("Carried Over"); rest spread into future.
int daysAgo = isCompleted ? 30 + (completedJobCount - 1) * 14
: isReadyForPickup ? 5 + (visitIdx % 8)
: isInProgress ? 10 + (visitIdx % 40)
: 2 + (visitIdx % 15);
var createdDate = now.AddDays(-daysAgo);
var itemCount = 1 + (i % 3); var scheduledDate = isCompleted ? createdDate.AddDays(3 + (visitIdx % 5))
var items = new List<JobItem>(); : isReadyForPickup ? now.AddDays(visitIdx % 5)
: isInProgress ? now.AddDays(inProgressCount <= 3 ? -(4 - inProgressCount) : inProgressCount - 3)
: now.AddDays(3 + (visitIdx % 12));
var rushDays = priorityCode == "RUSH" ? 2 : priorityCode == "URGENT" ? 3 : 7;
var dueDate = scheduledDate.AddDays(rushDays);
var startedDate = isCompleted || isReadyForPickup || isInProgress ? (DateTime?)scheduledDate : null;
var completedDate = isCompleted || isReadyForPickup ? scheduledDate.AddDays(1) : (DateTime?)null;
for (int j = 0; j < itemCount; j++) // Per-customer value targeting: deterministic variance within the customer's price range
var range = maxVal - minVal;
var targetValue = minVal + range * ((ci * 7 + j * 13) % 100) / 100m;
var itemCount = 1 + (visitIdx % 3);
var items = new List<JobItem>();
for (int k = 0; k < itemCount; k++)
{ {
var (desc, color, sand, mask, mins) = ItemSpec(i, j); var (desc, color, sand, mask, mins) = ItemSpec(visitIdx, k);
var qty = 1 + (j % 3); var qty = 1 + (k % 3);
var unitPrice = linkedQuote != null && j == 0 var unitPrice = linkedQuote != null && k == 0
? Math.Round((linkedQuote.Total / itemCount), 2) ? Math.Round(linkedQuote.Total / itemCount, 2)
: Math.Round(75m + (i % 8) * 12.5m + j * 15m, 2); : Math.Round(targetValue / itemCount / qty, 2);
items.Add(new JobItem items.Add(new JobItem
{ {
Description = desc, Description = desc,
Quantity = qty, Quantity = qty,
ColorName = color, ColorName = color,
SurfaceAreaSqFt = 10m + j * 3.5m, SurfaceAreaSqFt = 10m + k * 3.5m,
UnitPrice = unitPrice, UnitPrice = unitPrice,
TotalPrice = unitPrice * qty, TotalPrice = unitPrice * qty,
LaborCost = Math.Round(unitPrice * qty * 0.35m, 2), LaborCost = Math.Round(unitPrice * qty * 0.35m, 2),
@@ -240,7 +339,7 @@ public partial class SeedDataService
JobNumber = $"{prefix}{seq:D4}", JobNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id, CustomerId = customer.Id,
QuoteId = linkedQuote?.Id, QuoteId = linkedQuote?.Id,
AssignedUserId = assignedUserId, AssignedUserId = shopUsers.Count > 0 ? shopUsers[visitIdx % shopUsers.Count].Id : null,
Description = linkedQuote?.Description Description = linkedQuote?.Description
?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}", ?? $"Powder coating services for {customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim()}",
JobStatusId = jobStatuses[statusCode], JobStatusId = jobStatuses[statusCode],
@@ -252,18 +351,20 @@ public partial class SeedDataService
QuotedPrice = quotedPrice, QuotedPrice = quotedPrice,
FinalPrice = finalPrice, FinalPrice = finalPrice,
IsRushJob = priorityCode == "RUSH", IsRushJob = priorityCode == "RUSH",
CustomerPO = linkedQuote?.CustomerPO ?? (i % 3 == 0 ? $"PO-{40000 + i}" : null), CustomerPO = customer.IsCommercial && visitIdx % 3 == 0 ? $"PO-{40000 + visitIdx}" : null,
SpecialInstructions = i % 6 == 0 ? "Customer supplied parts — handle with extra care." : SpecialInstructions = visitIdx % 6 == 0 ? "Customer supplied parts — handle with extra care." :
i % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null, visitIdx % 11 == 0 ? "Match existing color exactly — bring sample for approval." : null,
InternalNotes = i % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null, InternalNotes = visitIdx % 8 == 0 ? "Vintage parts — do not use aggressive blast media." : null,
RequiresCustomerApproval = i % 5 == 0, RequiresCustomerApproval = visitIdx % 5 == 0,
IsCustomerApproved = i % 5 != 0 || !isEarly, IsCustomerApproved = visitIdx % 5 != 0 || !isInProgress,
JobItems = items, JobItems = items,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = createdDate CreatedAt = createdDate,
// Set UpdatedAt to the historical event date so analytics charts group into the
// correct month. The EF interceptor only stamps UpdatedAt on Modified saves,
// leaving it null for seeded entities, which the analytics filter treats as excluded.
UpdatedAt = completedDate ?? (isInProgress ? scheduledDate : (DateTime?)null) ?? createdDate
}); });
seq++;
} }
await _context.Set<Job>().AddRangeAsync(jobs); await _context.Set<Job>().AddRangeAsync(jobs);
@@ -0,0 +1,146 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds maintenance records for all seeded equipment: historical completed records,
/// upcoming scheduled records, and one overdue record for the Pressure Pot so the
/// Equipment Maintenance report always has meaningful data.
/// </summary>
/// <remarks>
/// <para>
/// Each piece of equipment gets 2-3 completed historical maintenance records (up to
/// 12 months back) plus one upcoming scheduled record. The Pressure Pot additionally
/// has one overdue record (past due date, still Scheduled) to populate the overdue
/// indicator on the Equipment page.
/// </para>
/// <para>
/// Labor and parts costs are realistic for shop equipment maintenance, giving the
/// Equipment Maintenance Cost report non-trivial totals from day one.
/// </para>
/// <para>
/// Idempotency: bails early if any maintenance records already exist for this company's equipment.
/// </para>
/// </remarks>
private async Task<int> SeedMaintenanceRecordsAsync(Company company)
{
var equipmentIds = await _context.Set<Equipment>()
.IgnoreQueryFilters()
.Where(e => e.CompanyId == company.Id && !e.IsDeleted && SeededEquipmentSerials.Contains(e.SerialNumber))
.Select(e => e.Id)
.ToListAsync();
if (equipmentIds.Count == 0) return 0;
var existingCount = await _context.Set<MaintenanceRecord>()
.IgnoreQueryFilters()
.CountAsync(m => equipmentIds.Contains(m.EquipmentId));
if (existingCount > 0) return 0;
var equipment = await _context.Set<Equipment>()
.IgnoreQueryFilters()
.Where(e => equipmentIds.Contains(e.Id))
.ToListAsync();
// Try to grab a worker user to assign as performed-by
var worker = await _userManager.Users
.Where(u => u.CompanyId == company.Id && u.IsActive)
.FirstOrDefaultAsync();
var now = DateTime.UtcNow;
var records = new List<MaintenanceRecord>();
// Per-equipment maintenance spec: (type, daysAgoFirst, intervalDays, laborCost, partsCost, notes)
// Each equipment gets 2 completed records + 1 scheduled upcoming.
// The Pressure Pot also gets 1 overdue record.
static (string type, decimal labor, decimal parts, string desc, string work)
MaintSpec(int i) => (i % 5) switch
{
0 => ("Preventive", 120m, 45m, "Quarterly preventive maintenance", "Inspected elements, cleaned contacts, checked gaskets"),
1 => ("Inspection", 80m, 0m, "Monthly operational inspection", "Checked all systems, calibrated temperature probes"),
2 => ("Repair", 200m, 185m, "Filter and seal replacement", "Replaced intake filters and worn door seals"),
3 => ("Preventive", 140m, 60m, "Semi-annual preventive maintenance", "Lubricated moving parts, replaced wear items"),
_ => ("Inspection", 60m, 0m, "Pre-season inspection and cleaning", "Full operational test, cleaned all surfaces"),
};
int idx = 0;
foreach (var eq in equipment)
{
var isPressurePot = eq.SerialNumber == "CLM101223456"; // Media Blast Room / Pressure Pot
for (int r = 0; r < 2; r++)
{
var (mtype, labor, parts, desc, work) = MaintSpec(idx + r);
var daysAgo = 180 - r * 60 - (idx % 4) * 15;
var scheduled = now.AddDays(-daysAgo);
var total = labor + parts;
records.Add(new MaintenanceRecord
{
EquipmentId = eq.Id,
MaintenanceType = mtype,
Status = MaintenanceStatus.Completed,
Priority = MaintenancePriority.Normal,
ScheduledDate = scheduled,
CompletedDate = scheduled.AddDays(1),
PerformedById = worker?.Id,
AssignedUserId = worker?.Id,
Description = desc,
WorkPerformed = work,
LaborCost = labor,
PartsCost = parts,
TotalCost = total,
DowntimeHours = 2m + r,
CompanyId = company.Id,
CreatedAt = scheduled.AddDays(-7)
});
}
// One upcoming scheduled record
var upcomingDays = 15 + (idx % 30);
records.Add(new MaintenanceRecord
{
EquipmentId = eq.Id,
MaintenanceType = "Preventive",
Status = MaintenanceStatus.Scheduled,
Priority = MaintenancePriority.Normal,
ScheduledDate = now.AddDays(upcomingDays),
AssignedUserId = worker?.Id,
Description = "Scheduled preventive maintenance",
LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
CompanyId = company.Id,
CreatedAt = now.AddDays(-7)
});
// Overdue record for the pressure pot only
if (isPressurePot)
{
records.Add(new MaintenanceRecord
{
EquipmentId = eq.Id,
MaintenanceType = "Repair",
Status = MaintenanceStatus.Scheduled,
Priority = MaintenancePriority.High,
ScheduledDate = now.AddDays(-20), // overdue
AssignedUserId = worker?.Id,
Description = "Filter replacement — OVERDUE",
Notes = "Media filter became clogged ahead of schedule. Shop is running reduced blast capacity until repaired.",
LaborCost = 0m, PartsCost = 0m, TotalCost = 0m,
CompanyId = company.Id,
CreatedAt = now.AddDays(-30)
});
}
idx++;
}
await _context.Set<MaintenanceRecord>().AddRangeAsync(records);
await _context.SaveChangesAsync();
return records.Count;
}
}
@@ -0,0 +1,228 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Seeds 7 purchase orders across three vendors covering a 3-month window:
/// 3 Received (historical), 2 Submitted (in-flight), and 2 Draft (pending approval).
/// This gives every PO status a visible example for demo walkthroughs.
/// </summary>
/// <remarks>
/// Vendors are resolved by partial name match against the company's vendor list — the
/// same approach used by <see cref="SeedBillsAsync"/>. PO numbers follow the convention
/// <c>PO-YYMM-####</c> stamped at seed time.
///
/// Received POs link back to the <see cref="Bill"/> that was created after receipt when
/// one with a matching vendor invoice number exists; otherwise <c>BillId</c> is left null
/// so the PO still seeds cleanly even if bills were skipped.
///
/// Idempotency: returns 0 immediately if any purchase orders already exist for the company.
/// </remarks>
/// <param name="company">The tenant company to seed purchase orders for.</param>
/// <returns>Number of PO records inserted, or 0 if already seeded.</returns>
private async Task<int> SeedPurchaseOrdersAsync(Company company)
{
var existingCount = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.CountAsync(p => p.CompanyId == company.Id && !p.IsDeleted);
if (existingCount > 0)
return 0;
var vendors = await _context.Set<Vendor>()
.IgnoreQueryFilters()
.Where(v => v.CompanyId == company.Id && !v.IsDeleted)
.ToListAsync();
if (vendors.Count == 0)
return 0;
var prismatic = vendors.FirstOrDefault(v => v.CompanyName.Contains("Prismatic")) ?? vendors.First();
var columbia = vendors.FirstOrDefault(v => v.CompanyName.Contains("Columbia")) ?? vendors.First();
var harbor = vendors.FirstOrDefault(v => v.CompanyName.Contains("Harbor")) ?? vendors.First();
var grainger = vendors.FirstOrDefault(v => v.CompanyName.Contains("Grainger")) ?? vendors.First();
var localSupply = vendors.FirstOrDefault(v => v.CompanyName.Contains("Local")) ?? vendors.First();
// Resolve inventory item IDs for PO line items (optional — may be null if inventory
// wasn't seeded yet; the PO seeds cleanly either way using the Description field).
var glossBlack = await _context.Set<InventoryItem>().IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-GBK-001") && !i.IsDeleted);
var matteBlack = await _context.Set<InventoryItem>().IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-MBK-001") && !i.IsDeleted);
var superChrome = await _context.Set<InventoryItem>().IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-CHR-001") && !i.IsDeleted);
var candyRed = await _context.Set<InventoryItem>().IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-PWD-CRD-001") && !i.IsDeleted);
var blastMedia = await _context.Set<InventoryItem>().IgnoreQueryFilters()
.FirstOrDefaultAsync(i => i.CompanyId == company.Id && i.SKU.EndsWith("-BLM-001") && !i.IsDeleted);
var now = DateTime.UtcNow;
var pfx = $"PO-{now:yy}{now.Month:D2}-";
var seq = 1;
var seeded = 0;
async Task<PurchaseOrder> AddPO(PurchaseOrder po)
{
po.PoNumber = $"{pfx}{seq++:D4}";
po.CompanyId = company.Id;
foreach (var item in po.Items)
{
item.CompanyId = company.Id;
item.CreatedAt = po.OrderDate;
}
await _context.Set<PurchaseOrder>().AddAsync(po);
await _context.SaveChangesAsync();
seeded++;
return po;
}
// ── RECEIVED (historical — tied to bills already in the system) ───────
// PO-1: Prismatic Powders — powder restock 3 months ago (Received, matches bill PP-77211)
await AddPO(new PurchaseOrder
{
VendorId = prismatic.Id,
Status = PurchaseOrderStatus.Received,
OrderDate = now.AddDays(-95),
ExpectedDeliveryDate = now.AddDays(-80),
ReceivedDate = now.AddDays(-82),
SubTotal = 1_145.00m,
TotalAmount = 1_145.00m,
Notes = "Quarterly powder restock — Q1",
CreatedAt = now.AddDays(-95),
Items =
{
new PurchaseOrderItem { InventoryItemId = matteBlack?.Id, Description = "Matte Black Powder — 50 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 178.00m, LineTotal = 356.00m },
new PurchaseOrderItem { InventoryItemId = glossBlack?.Id, Description = "Gloss Black Powder — 50 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 165.00m, LineTotal = 330.00m },
new PurchaseOrderItem { Description = "Satin Silver Powder — 25 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 144.50m, LineTotal = 289.00m },
new PurchaseOrderItem { Description = "Masking Tape & Plugs Kit", UnitOfMeasure = "kit", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 170.00m, LineTotal = 170.00m }
}
});
// PO-2: Columbia Coatings — specialty colors 2 months ago (Received, matches bill CC-4401)
await AddPO(new PurchaseOrder
{
VendorId = columbia.Id,
Status = PurchaseOrderStatus.Received,
OrderDate = now.AddDays(-70),
ExpectedDeliveryDate = now.AddDays(-58),
ReceivedDate = now.AddDays(-60),
SubTotal = 986.00m,
TotalAmount = 986.00m,
Notes = "Specialty metallic & candy colors order",
CreatedAt = now.AddDays(-70),
Items =
{
new PurchaseOrderItem { InventoryItemId = candyRed?.Id, Description = "Candy Red Metallic — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 3, QuantityReceived = 3, UnitCost = 145.00m, LineTotal = 435.00m },
new PurchaseOrderItem { InventoryItemId = superChrome?.Id, Description = "Chrome Effect Powder — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 168.00m, LineTotal = 336.00m },
new PurchaseOrderItem { Description = "Hammertone Bronze — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 150.50m, LineTotal = 150.50m },
new PurchaseOrderItem { Description = "Ground Straps & Hooks", UnitOfMeasure = "lot", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 64.50m, LineTotal = 64.50m }
}
});
// PO-3: Harbor Freight Tools — consumables 6 weeks ago (Received, matches bill HBF-18822 timing)
await AddPO(new PurchaseOrder
{
VendorId = harbor.Id,
Status = PurchaseOrderStatus.Received,
OrderDate = now.AddDays(-48),
ExpectedDeliveryDate = now.AddDays(-40),
ReceivedDate = now.AddDays(-42),
SubTotal = 412.50m,
TotalAmount = 412.50m,
Notes = "Shop consumables & hardware restock",
CreatedAt = now.AddDays(-48),
Items =
{
new PurchaseOrderItem { Description = "J-Hook Hangers Assortment", UnitOfMeasure = "pkg", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 89.75m, LineTotal = 179.50m },
new PurchaseOrderItem { Description = "Masking Caps — Mixed (100-pack)", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 2, UnitCost = 60.00m, LineTotal = 120.00m },
new PurchaseOrderItem { Description = "Wire Brushes & Abrasives", UnitOfMeasure = "lot", QuantityOrdered = 1, QuantityReceived = 1, UnitCost = 113.00m, LineTotal = 113.00m }
}
});
// ── SUBMITTED (in-flight — awaiting delivery) ─────────────────────────
// PO-4: Grainger Industrial Supply — safety equipment & filter replacement (matches open GRG-7714 partial bill)
await AddPO(new PurchaseOrder
{
VendorId = grainger.Id,
Status = PurchaseOrderStatus.Submitted,
OrderDate = now.AddDays(-14),
ExpectedDeliveryDate = now.AddDays(3),
SubTotal = 648.00m,
TotalAmount = 648.00m,
Notes = "Blast room filter replacement + safety restocking",
CreatedAt = now.AddDays(-14),
Items =
{
new PurchaseOrderItem { Description = "HEPA Filter Cartridges — 12-pack", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 189.00m, LineTotal = 378.00m },
new PurchaseOrderItem { Description = "Blast Nozzle Tungsten — 3/8\"", UnitOfMeasure = "ea", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 85.00m, LineTotal = 170.00m },
new PurchaseOrderItem { Description = "Safety Respirators (10-pack)", UnitOfMeasure = "box", QuantityOrdered = 1, QuantityReceived = 0, UnitCost = 100.00m, LineTotal = 100.00m }
}
});
// PO-5: Prismatic Powders — current month powder order (matches open bill PP-88530)
await AddPO(new PurchaseOrder
{
VendorId = prismatic.Id,
Status = PurchaseOrderStatus.Submitted,
OrderDate = now.AddDays(-8),
ExpectedDeliveryDate = now.AddDays(7),
SubTotal = 1_050.00m,
TotalAmount = 1_050.00m,
Notes = "June powder restock — Matte Black + seasonal Gloss Red",
CreatedAt = now.AddDays(-8),
Items =
{
new PurchaseOrderItem { InventoryItemId = matteBlack?.Id, Description = "Matte Black Powder — 25 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 6, QuantityReceived = 0, UnitCost = 89.00m, LineTotal = 534.00m },
new PurchaseOrderItem { Description = "Gloss Red Powder — 10 lb bags", UnitOfMeasure = "bag", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 132.00m, LineTotal = 264.00m },
new PurchaseOrderItem { Description = "Hanging Racks (10-pack)", UnitOfMeasure = "pkg", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 126.00m, LineTotal = 252.00m }
}
});
// ── DRAFT (pending review / approval) ─────────────────────────────────
// PO-6: Harbor Freight Tools — shop tools pending manager approval
await AddPO(new PurchaseOrder
{
VendorId = harbor.Id,
Status = PurchaseOrderStatus.Draft,
OrderDate = now.AddDays(-3),
ExpectedDeliveryDate = now.AddDays(10),
SubTotal = 318.75m,
TotalAmount = 318.75m,
Notes = "Monthly hardware restock — needs approval before submit",
InternalNotes = "Manager review requested: higher than normal month due to extra hooks for upcoming Apex Motorsports batch.",
CreatedAt = now.AddDays(-3),
Items =
{
new PurchaseOrderItem { Description = "Hanging Racks & J-Hooks", UnitOfMeasure = "pkg", QuantityOrdered = 1, QuantityReceived = 0, UnitCost = 198.75m, LineTotal = 198.75m },
new PurchaseOrderItem { Description = "Masking Caps — Mixed (100-pack)", UnitOfMeasure = "box", QuantityOrdered = 2, QuantityReceived = 0, UnitCost = 60.00m, LineTotal = 120.00m }
}
});
// PO-7: Local Industrial Supply — blast media restock (inventory currently at zero)
await AddPO(new PurchaseOrder
{
VendorId = localSupply.Id,
Status = PurchaseOrderStatus.Draft,
OrderDate = now.AddDays(-1),
ExpectedDeliveryDate = now.AddDays(5),
SubTotal = 385.00m,
TotalAmount = 385.00m,
Notes = "URGENT — blast media out of stock, production blocked on Pressure Pot Blaster",
InternalNotes = "Rush order requested; call LIS rep for same-week delivery.",
CreatedAt = now.AddDays(-1),
Items =
{
new PurchaseOrderItem { InventoryItemId = blastMedia?.Id, Description = "Aluminum Oxide #80 Grit — 100 lb bag", UnitOfMeasure = "bag", QuantityOrdered = 5, QuantityReceived = 0, UnitCost = 77.00m, LineTotal = 385.00m }
}
});
return seeded;
}
}
@@ -6,39 +6,31 @@ namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService public partial class SeedDataService
{ {
/// <summary> /// <summary>
/// Seeds 75 realistic powder coating quotes spread across seven item categories /// Seeds 35 realistic quotes spanning the full status lifecycle: Draft, Sent, Approved,
/// (automotive wheels, industrial, architectural, fitness, marine, furniture, misc) /// Rejected, and Expired — with Approved as the clear majority so that SeedJobsAsync
/// with a realistic status distribution: Draft (8), Sent (12), Approved (35), /// has enough linked quotes to demonstrate the quote-to-job conversion workflow.
/// Rejected (10), and Expired (10).
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Idempotency: returns 0 immediately if any non-deleted quotes already exist for /// Customers are loaded in Id-ascending order (matching the job seeder) and a
/// this company, preventing duplicate quote sets on repeated seed runs. /// shuffled visit schedule interleaves commercial and individual customers so that
/// statuses and customer types are naturally mixed rather than appearing in blocks.
/// </para> /// </para>
/// <para> /// <para>
/// Quote numbers follow the production format <c>QT-YYMM-####</c>. The seeder scans /// Status pool (fixed seed 55): Approved &times;18, Sent &times;8, Draft &times;4,
/// existing numbers with the current month prefix and starts its sequence above the /// Rejected &times;3, Expired &times;2. Shuffled with Fisher-Yates (fixed seed 55)
/// current maximum so seeded quotes never collide with real quotes created in the /// so every reset produces the same interleaved sequence.
/// same month.
/// </para> /// </para>
/// <para> /// <para>
/// Pricing is deliberately simple (sqft × $8.50 + variance) rather than running through /// Per-customer quote counts mirror real business activity: top commercial accounts
/// <c>IPricingCalculationService</c> — this avoids a dependency on company operating cost /// (Carolina Fabrication, Apex, Triangle) generate the most quotes; prospect customers
/// config that may not yet be populated when seed runs. /// (Jennifer Clark, Lisa Anderson, Michelle Brown) have quotes but no jobs.
/// </para> /// </para>
/// <para> /// <para>
/// Tax-exempt customers automatically receive a 0 % tax rate (matching the production /// Dates spread over 7185 days back (roughly 6 months) with a small jitter term so
/// behaviour in <c>QuotesController</c>). Rush fees (15 %) are added every 12th quote. /// historical charts show a natural activity curve without obviously linear spacing.
/// </para>
/// <para>
/// The method requires that customers and quote-status lookup rows already exist for the
/// company; it returns 0 if either dependency is missing so that the overall seed
/// operation degrades gracefully rather than throwing.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed quotes for.</param>
/// <returns>Number of quotes inserted, or 0 if already seeded or dependencies are missing.</returns>
private async Task<int> SeedQuotesAsync(Company company) private async Task<int> SeedQuotesAsync(Company company)
{ {
var existingCount = await _context.Set<Quote>() var existingCount = await _context.Set<Quote>()
@@ -56,14 +48,14 @@ public partial class SeedDataService
if (quoteStatuses.Count == 0) if (quoteStatuses.Count == 0)
return 0; return 0;
// Load all commercial customers // Load in Id order — same ordering as the job seeder so CustomerProfile(ci) indices align
var customers = await _context.Set<Customer>() var allCustomers = await _context.Set<Customer>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && c.IsCommercial && !c.IsDeleted) .Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.OrderBy(c => c.Id) .OrderBy(c => c.Id)
.ToListAsync(); .ToListAsync();
if (customers.Count == 0) if (allCustomers.Count == 0)
return 0; return 0;
var preparedByUser = await _userManager.Users var preparedByUser = await _userManager.Users
@@ -71,8 +63,6 @@ public partial class SeedDataService
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
// Avoid duplicate quote numbers
var prefix = $"QT-{now:yy}{now.Month:D2}-"; var prefix = $"QT-{now:yy}{now.Month:D2}-";
var existing = await _context.Set<Quote>() var existing = await _context.Set<Quote>()
.IgnoreQueryFilters() .IgnoreQueryFilters()
@@ -84,183 +74,203 @@ public partial class SeedDataService
if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x; if (n.Length >= 12 && int.TryParse(n.Substring(8, 4), out var x) && x > maxNum) maxNum = x;
var seq = maxNum + 1; var seq = maxNum + 1;
// ── Data arrays for varied, realistic content ───────────────────────── // ── Per-customer quote counts (must sum to 35) ─────────────────────────
// Indices match the Id-ascending customer list from SeedCustomersAsync:
// Returns an array of realistic item descriptions for a given category bucket (06). // 0=Carolina Fabrication, 1=Apex Motorsports, 2=Triangle Offroad,
// Using a local static function keeps the description data close to where it is // 3=Smith Welding, 4=Raleigh Architectural, 5=East Coast Powderworks,
// consumed and avoids polluting the partial class with per-seeder detail arrays. // 6=Piedmont Metal Works, 7=Cary Industrial, 8=Durham Tech, 9=Wake County Fleet,
static string[] ItemDescs(int category) => category switch // 1026 = individual residential customers
static int QuotesFor(int ci) => ci switch
{ {
0 => new[] { 0 => 4, // Carolina Fabrication — most quotes
"18\" Aluminum Wheels — Matte Black", 1 => 3, // Apex Motorsports
"17\" Steel Wheels — Gloss White", 2 => 3, // Triangle Offroad
"20\" Alloy Wheels — Satin Silver", 3 => 3, // Smith Welding
"16\" Chrome Replica Wheels — Gloss Black", 4 => 2, // Raleigh Architectural Metals
"Motorcycle Frame — Flat Black", 5 => 2, // East Coast Powderworks
"Motorcycle Swingarm & Forks — Gloss Black", 6 => 1, // Piedmont Metal Works
"Exhaust Headers — High-Temp Flat Black", 7 => 1, // Cary Industrial Solutions
"Intake Manifold — Wrinkle Red", 8 => 2, // Durham Tech Equipment
"Valve Covers — Gloss Red", 9 => 3, // Wake County Fleet Services
"Brake Calipers — Gloss Yellow" }, 10 => 1, // John Davis
1 => new[] { 11 => 1, // Sarah Jenkins
"Steel Shelving Units (10-shelf set)", 12 => 1, // Mike Thompson
"Industrial Equipment Frame", 13 => 1, // Robert Miller
"Machine Guard Panels", 14 => 1, // Jennifer Clark — prospect (quote only, no job)
"Conveyor Frame Sections", 15 => 1, // David Wilson
"Heavy Equipment Brackets", 16 => 1, // Lisa Anderson — prospect
"Pump Housing Assembly", 17 => 1, // Thomas Harris
"Control Panel Enclosure", 19 => 1, // James Taylor
"Storage Rack System", 20 => 1, // Michelle Brown — prospect
"Scissor Lift Platform", 21 => 1, // Chris Lee
"Compressor Tank" }, _ => 0,
2 => new[] {
"Aluminum Window Frames (set of 8)",
"Steel Handrail System — 40 ft",
"Wrought Iron Fence Panels (6-panel set)",
"Entry Gate — Custom Design",
"Structural Steel Columns (set of 4)",
"Balcony Railing — Satin Black",
"Steel Door Frames (3 units)",
"Architectural Steel Beams",
"Decorative Ironwork — Stair Baluster",
"Aluminum Storefront Frame" },
3 => new[] {
"Commercial Gym Equipment Frame",
"Weight Rack & Benches",
"Outdoor Playground Equipment Parts",
"Bicycle Frame — Gloss Blue",
"BMX Frame Set — Candy Red" },
4 => new[] {
"Boat Trailer Frame — Marine Grade",
"Aluminum Dock Cleats & Hardware",
"Outboard Motor Bracket",
"Marine Fuel Tank Brackets" },
5 => new[] {
"Restaurant Chair Frames (set of 20)",
"Steel Dining Table Bases (set of 8)",
"Patio Furniture Set — 6 Pieces",
"Café Chairs — Hammered Bronze (12-pc)",
"Commercial Bar Stools (set of 10)" },
_ => new[] {
"Custom Steel Parts — Batch Order",
"Agricultural Equipment Panels",
"Traffic Sign Frames (set of 15)",
"Utility Trailer Hitch Assembly",
"Solar Panel Mounting Brackets" }
}; };
// Returns finish color, prep flags, estimated minutes, and surface area for item index i. // ── Status pool: realistic distribution (total = 35) ───────────────────
// Cycling modulo 9 ensures variety across all 75 quotes without requiring a large lookup table. // Approved is the majority; Rejected and Expired are rare.
static (string color, bool sandblast, bool mask, int minutes, decimal sqft) ItemSpec(int i) => (i % 9) switch var statusPool = new List<string>();
foreach (var (code, count) in new (string Code, int Count)[]
{ {
0 => ("Matte Black", true, false, 45, 12.0m), ("APPROVED", 18),
1 => ("Gloss White", false, false, 30, 8.5m), ("SENT", 8),
2 => ("Satin Silver", true, true, 60, 15.0m), ("DRAFT", 4),
3 => ("Candy Red", false, true, 35, 9.0m), ("REJECTED", 3),
4 => ("Textured Gray", true, false, 50, 18.0m), ("EXPIRED", 2),
5 => ("Gloss Black", true, false, 40, 11.0m), })
6 => ("Hammered Bronze", false, false, 55, 20.0m),
7 => ("Satin Graphite", true, true, 65, 25.0m),
_ => ("Flat Black", true, false, 35, 10.0m)
};
// Maps quote index to a status code following the distribution plan above.
// APPROVED is the majority (35/75) to give SeedJobsAsync enough approved quotes to link jobs to.
static string StatusFor(int i) => i switch
{ {
< 8 => "DRAFT", for (int k = 0; k < count; k++) statusPool.Add(code);
< 20 => "SENT", }
< 55 => "APPROVED", // Fisher-Yates shuffle — fixed seed for deterministic resets
< 65 => "REJECTED", var statusRng = new Random(55);
_ => "EXPIRED" for (int k = statusPool.Count - 1; k > 0; k--)
};
var quotes = new List<Quote>();
for (int i = 0; i < 75; i++)
{ {
var customer = customers[i % customers.Count]; var swap = statusRng.Next(k + 1);
var statusCode = StatusFor(i); (statusPool[k], statusPool[swap]) = (statusPool[swap], statusPool[k]);
}
// Spread creation dates over the past 90 days; older first // ── Customer visit schedule: interleave commercial (ci 09) and individual (ci 10+) ──
var daysAgo = 90 - (int)(i * 1.2); // Same two-pool approach as the job seeder — prevents commercial customers from
// clustering at the top of any list sorted by quote number or date.
var commercialVisits = new List<int>();
var individualVisits = new List<int>();
for (int ci = 0; ci < allCustomers.Count; ci++)
{
var count = QuotesFor(ci);
for (int j = 0; j < count; j++)
(ci < 10 ? commercialVisits : individualVisits).Add(ci);
}
var rngC = new Random(42);
for (int k = commercialVisits.Count - 1; k > 0; k--)
{
var swap = rngC.Next(k + 1);
(commercialVisits[k], commercialVisits[swap]) = (commercialVisits[swap], commercialVisits[k]);
}
var rngI = new Random(17);
for (int k = individualVisits.Count - 1; k > 0; k--)
{
var swap = rngI.Next(k + 1);
(individualVisits[k], individualVisits[swap]) = (individualVisits[swap], individualVisits[k]);
}
var visitSchedule = new List<int>(commercialVisits.Count + individualVisits.Count);
double indStride = individualVisits.Count > 0
? (commercialVisits.Count + 1.0) / (individualVisits.Count + 1.0)
: double.MaxValue;
int indInsertIdx = 0;
for (int comIdx = 0; comIdx < commercialVisits.Count; comIdx++)
{
while (indInsertIdx < individualVisits.Count && (indInsertIdx + 1) * indStride <= comIdx + 1)
visitSchedule.Add(individualVisits[indInsertIdx++]);
visitSchedule.Add(commercialVisits[comIdx]);
}
while (indInsertIdx < individualVisits.Count)
visitSchedule.Add(individualVisits[indInsertIdx++]);
// ── Item catalogue — 15 common powder coating jobs ──────────────────────
static (string desc, string color, bool sand, bool mask, int mins, decimal sqft) ItemSpec(int i) =>
(i % 15) switch
{
0 => ("18\" Aluminum Wheels (set of 4)", "Gloss Black", false, false, 45, 12.0m),
1 => ("17\" Steel Wheels (set of 4)", "Signal White", false, false, 30, 8.5m),
2 => ("Jeep Bumper & Rock Sliders", "Matte Black", true, false, 60, 15.0m),
3 => ("Motorcycle Frame", "Matte Black", true, false, 90, 14.0m),
4 => ("Steel Shelving Units (10-shelf set)", "Textured Gray", true, false, 55, 18.0m),
5 => ("Industrial Machine Guard Panels", "Safety Yellow", false, false, 35, 20.0m),
6 => ("Aluminum Window Frames (set of 8)", "Satin Bronze", false, true, 50, 22.0m),
7 => ("Steel Handrail System — 40 ft", "Gloss Black", true, false, 120, 40.0m),
8 => ("Wrought Iron Entry Gate", "Hammered Black", true, false, 180, 35.0m),
9 => ("Brake Calipers (set of 4)", "Candy Red", false, true, 35, 4.0m),
10 => ("Restaurant Chair Frames (set of 20)", "Hammered Bronze", false, false, 60, 30.0m),
11 => ("Bicycle Frame", "Candy Red", true, true, 60, 6.5m),
12 => ("Compressor Tank", "Safety Orange", true, false, 45, 10.0m),
13 => ("Patio Furniture Set (6 pieces)", "Textured Beige", false, false, 50, 24.0m),
_ => ("Custom Steel Fabrication — Batch", "Matte Black", true, false, 40, 15.0m)
};
var quotes = new List<Quote>();
var quotesByCustomer = new int[allCustomers.Count]; // within-customer quote counter
for (int visitIdx = 0; visitIdx < visitSchedule.Count; visitIdx++)
{
var ci = visitSchedule[visitIdx];
var customer = allCustomers[ci];
var j = quotesByCustomer[ci]++; // within-customer quote index (unused except for date jitter)
var statusCode = statusPool[visitIdx];
// Dates spread 7185 days back; small jitter via ci so identical-status quotes
// for the same customer don't land on exactly the same day.
var daysAgo = Math.Max(7, 185 - visitIdx * 5 + (ci % 5));
var quoteDate = now.AddDays(-daysAgo); var quoteDate = now.AddDays(-daysAgo);
var expireDate = quoteDate.AddDays(30); var expireDate = quoteDate.AddDays(30);
var category = i % 7; var itemCount = 1 + (visitIdx % 3);
var descs = ItemDescs(category);
var itemCount = 1 + (i % 3);
var items = new List<QuoteItem>(); var items = new List<QuoteItem>();
for (int j = 0; j < itemCount; j++)
for (int k = 0; k < itemCount; k++)
{ {
var desc = descs[(i + j) % descs.Length]; var (desc, color, sand, mask, mins, sqft) = ItemSpec(visitIdx * 3 + k);
var (color, sand, mask, mins, sqft) = ItemSpec(i + j); var qty = 1 + (k % 4);
var qty = 1 + (j % 4); var tierMult = 1m - ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m);
// Unit price scales with surface area and adds a modest multiplier per customer tier var unitPrice = Math.Round(sqft * 8.50m * tierMult + (visitIdx % 6) * 5.0m, 2);
var tierMult = 1m + ((customer.PricingTier?.DiscountPercent ?? 0m) / 100m * -1m);
var unitPrice = Math.Round(sqft * 8.50m * tierMult + (i % 5) * 4.5m, 2);
items.Add(new QuoteItem items.Add(new QuoteItem
{ {
Description = desc, Description = desc,
Quantity = qty, Quantity = qty,
SurfaceAreaSqFt = sqft * qty, SurfaceAreaSqFt = sqft * qty,
UnitPrice = unitPrice, UnitPrice = unitPrice,
TotalPrice = unitPrice * qty, TotalPrice = unitPrice * qty,
RequiresSandblasting = sand, RequiresSandblasting = sand,
RequiresMasking = mask, RequiresMasking = mask,
EstimatedMinutes = mins, EstimatedMinutes = mins,
Complexity = (i % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" }, Complexity = (visitIdx % 4) switch { 0 => "Simple", 1 => "Moderate", 2 => "Complex", _ => "Simple" },
Notes = j == 0 && i % 5 == 0 ? $"{color} finish requested" : null, Notes = k == 0 && visitIdx % 5 == 0 ? $"Customer requested {color} — confirm shade before run." : null,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = quoteDate CreatedAt = quoteDate
}); });
} }
var subtotal = items.Sum(it => it.TotalPrice); var subtotal = items.Sum(it => it.TotalPrice);
var discountPct = customer.PricingTier?.DiscountPercent ?? 0m; var discountPct = customer.PricingTier?.DiscountPercent ?? 0m;
var discountAmt = Math.Round(subtotal * discountPct / 100m, 2); var discountAmt = Math.Round(subtotal * discountPct / 100m, 2);
var afterDiscount = subtotal - discountAmt; var afterDiscount = subtotal - discountAmt;
var taxPct = customer.IsTaxExempt ? 0m : 7.5m; var taxPct = customer.IsTaxExempt ? 0m : 7.5m;
var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2); var taxAmt = Math.Round(afterDiscount * taxPct / 100m, 2);
var rushFee = i % 12 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m; var rushFee = visitIdx % 10 == 0 ? Math.Round(afterDiscount * 0.15m, 2) : 0m;
var total = afterDiscount + taxAmt + rushFee; var total = afterDiscount + taxAmt + rushFee;
var quote = new Quote quotes.Add(new Quote
{ {
QuoteNumber = $"{prefix}{seq:D4}", QuoteNumber = $"{prefix}{seq:D4}",
CustomerId = customer.Id, CustomerId = customer.Id,
PreparedById = preparedByUser?.Id, PreparedById = preparedByUser?.Id,
QuoteStatusId = quoteStatuses[statusCode], QuoteStatusId = quoteStatuses[statusCode],
IsCommercial = customer.IsCommercial, IsCommercial = customer.IsCommercial,
IsRushJob = i % 12 == 0, IsRushJob = visitIdx % 10 == 0,
QuoteDate = quoteDate, QuoteDate = quoteDate,
ExpirationDate = expireDate, ExpirationDate = expireDate,
SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null, SentDate = statusCode != "DRAFT" ? quoteDate.AddDays(1) : null,
ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null, ApprovedDate = statusCode == "APPROVED" ? quoteDate.AddDays(4) : null,
ItemsSubtotal = subtotal, ItemsSubtotal = subtotal,
SubTotal = subtotal, SubTotal = subtotal,
DiscountPercent = discountPct, DiscountPercent = discountPct,
DiscountAmount = discountAmt, DiscountAmount = discountAmt,
TaxPercent = taxPct, TaxPercent = taxPct,
TaxAmount = taxAmt, TaxAmount = taxAmt,
RushFee = rushFee, RushFee = rushFee,
Total = total, Total = total,
Description = $"Powder coating services — {descs[i % descs.Length].Split('')[0].Trim()}", Description = $"Powder coating services — {items[0].Description.Split('(')[0].TrimEnd()}",
Terms = customer.PaymentTerms ?? "Net 30", Terms = customer.PaymentTerms ?? "Net 30",
Notes = i % 7 == 0 ? "Customer requested color sample before full run." : Notes = visitIdx % 8 == 0 ? "Customer requested color sample before full production run." :
i % 13 == 0 ? "Rush turnaround requested — 3 business days." : null, visitIdx % 13 == 0 ? "Rush turnaround requested — 3 business days." : null,
CustomerPO = i % 2 == 0 ? $"PO-{30000 + i}" : null, CustomerPO = customer.IsCommercial && visitIdx % 2 == 0 ? $"PO-{30000 + visitIdx}" : null,
RequiresDeposit = i % 4 == 0, RequiresDeposit = visitIdx % 4 == 0,
DepositPercent = i % 4 == 0 ? 50m : 0m, DepositPercent = visitIdx % 4 == 0 ? 50m : 0m,
QuoteItems = items, QuoteItems = items,
CompanyId = company.Id, CompanyId = company.Id,
CreatedAt = quoteDate, CreatedAt = quoteDate,
UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1) UpdatedAt = statusCode == "DRAFT" ? quoteDate : quoteDate.AddDays(1)
}; });
quotes.Add(quote);
seq++; seq++;
} }
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
namespace PowderCoating.Infrastructure.Services; namespace PowderCoating.Infrastructure.Services;
@@ -12,14 +13,17 @@ public partial class SeedDataService
/// </summary> /// </summary>
private static readonly string[] SeededCustomerEmails = private static readonly string[] SeededCustomerEmails =
[ [
"john.smith@acmemfg.com", "sjohnson@precisionauto.com", "mchen@urbanrailings.com", // Commercial — NC Triangle area
"lmartinez@fitequip.com", "dwilliams@metrota.gov", "rtaylor@classicwheels.com", "matt@carolinafab.com", "ctanner@apexmotorsports.com", "jpruitt@triangleoffroad.com",
"janderson@indfurniture.com", "cbrown@motorsportscustom.com", "adavis@greenenergy.com", "bsmith@smithwelding.com", "kmorales@raleigharchitectural.com", "tgreco@eastcoastpw.com",
"tmiller@heritagemetal.com", "pwilson@marineequip.com", "kgarcia@commercialhvac.com", "dshaw@piedmontmetalworks.com", "lpatel@caryindustrial.com", "rblake@durhamtech.com",
"nmartinez@playgroundusa.com", "blee@officesystems.com", "swhite@agequipment.com", "mcoleman@wakecountyfleet.gov",
"jthompson@email.com", "mharris@email.com", "wclark@email.com", "elewis@email.com", // Individual residential
"rwalker@email.com", "bhall@email.com", "jallen@email.com", "syoung@email.com", "jdavis@email.com", "sjenkins@email.com", "mthompson@email.com", "rmiller@email.com",
"cking@email.com", "lwright@email.com" "jclark@email.com", "dwilson@email.com", "landerson@email.com", "tharris@email.com",
"kwhite@email.com", "jtaylor@email.com", "mbrown@email.com", "clee@email.com",
"agarcia@email.com", "kmartinez@email.com", "nrodriguez@email.com",
"bhall@email.com", "pyoung@email.com"
]; ];
/// <summary> /// <summary>
@@ -52,9 +56,11 @@ public partial class SeedDataService
/// </summary> /// </summary>
private static readonly string[] SeededInventorySkuSuffixes = private static readonly string[] SeededInventorySkuSuffixes =
[ [
"-PWD-BLK-001", "-PWD-WHT-001", "-PWD-RED-001", "-PWD-BLU-001", // 6 powders
"-PWD-GRY-001", "-PWD-YEL-001", "-PWD-ORG-001", "-PWD-GRN-001", "-PWD-GBK-001", "-PWD-MBK-001", "-PWD-CHR-001", "-PWD-CRD-001",
"-CLN-001", "-MSK-001" "-PWD-SWH-001", "-PWD-IPU-001",
// 5 consumables
"-MSK-001", "-PLG-001", "-HKS-001", "-ACT-001", "-BLM-001"
]; ];
/// <summary> /// <summary>
@@ -74,28 +80,25 @@ public partial class SeedDataService
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// All queries use <c>IgnoreQueryFilters()</c> so that records already soft-deleted by users /// When <c>ForceRemoveAll = true</c> a topologically ordered pre-sweep deletes every
/// are still found and physically removed — this prevents orphaned data from accumulating /// child record for the company that has a NO_ACTION FK pointing at a parent we need
/// in the database after partial cleanup. /// to delete later. This avoids FK constraint errors regardless of how much user-created
/// data has accumulated since the last seed. The sweep order mirrors the FK dependency
/// graph derived from <c>sys.foreign_keys</c>:
/// OvenBatchItems → PowderUsageLogs → InAppNotifications → QuotePhotos → GiftCertificates
/// → JobTemplates → CreditMemoApplications → Refunds → CreditMemos → ReworkRecords
/// → Deposits → Payments → InventoryTransactions → Invoices → Appointments
/// → VendorCreditApplications → Bills → VendorCredits → PurchaseOrders → Expenses
/// → OvenBatches → (self-referential Jobs.OriginalJobId nulled via raw SQL)
/// then the main entity blocks run in safe order.
/// </para> /// </para>
/// <para> /// <para>
/// Child records (job items, quote items, transactions, maintenance records, etc.) are deleted /// For the selective (non-force) path the Customers block also pre-deletes Invoices,
/// first before their parent to avoid FK constraint violations. Each category is committed /// Payments, Deposits, QuotePhotos, and InAppNotifications that reference the seeded
/// with its own <c>SaveChangesAsync()</c> call so a failure in one category does not roll /// customer/job/quote IDs, so those deletes succeed even when the broader pre-sweep
/// back deletions already completed in an earlier category. /// has not run.
/// </para>
/// <para>
/// Lookup tables (job status, job priority, quote status) are intentionally NOT removed —
/// they are system-level data shared across the company's real records.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <param name="companyId">ID of the tenant company whose seed data should be removed.</param>
/// <param name="options">Flags controlling which data categories to delete.</param>
/// <returns>
/// A <see cref="SeedDataResult"/> with <c>Success = true</c> and a count of records removed,
/// or <c>Success = false</c> with an error message if the company was not found or an
/// exception was thrown.
/// </returns>
public async Task<SeedDataResult> RemoveSeedDataAsync(int companyId, RemoveSeedDataOptions options) public async Task<SeedDataResult> RemoveSeedDataAsync(int companyId, RemoveSeedDataOptions options)
{ {
var result = new SeedDataResult { Success = true }; var result = new SeedDataResult { Success = true };
@@ -115,24 +118,182 @@ public partial class SeedDataService
return result; return result;
} }
// --- Customers (+ their jobs, quotes, and related items) --- // ── ForceRemoveAll pre-sweep ──────────────────────────────────────────
// Deletes every record that has a NO_ACTION FK pointing at a table we delete
// later. Order follows the FK dependency graph (leaves first, roots last).
// Each tier is committed before the next so EF's change tracker stays clean.
if (options.ForceRemoveAll)
{
// Local helper: delete all rows of T for this company, return count.
// All entities inherit BaseEntity which exposes CompanyId.
async Task<int> Sweep<T>() where T : BaseEntity
{
var rows = await _context.Set<T>().IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId).ToListAsync();
if (rows.Any()) _context.Set<T>().RemoveRange(rows);
return rows.Count;
}
// Tier 1 — pure leaf records (block nothing of their own)
await Sweep<JobTimeEntry>(); // FK → Jobs (Cascade by convention — sweep before jobs)
await Sweep<EmployeeClockEntry>(); // RESTRICT → ApplicationUser — must go before workers
await Sweep<JobNote>(); // NO_ACTION → Jobs
await Sweep<CustomerNote>(); // NO_ACTION → Customers
await Sweep<ReworkRecord>(); // NO_ACTION → Jobs
await Sweep<Deposit>(); // NO_ACTION → Customers, Jobs, Invoices, Quotes
await Sweep<BankReconciliation>(); // FK → Accounts (accounts stay, but sweep for clean reset)
await Sweep<OvenBatchItem>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<PowderUsageLog>(); // NO_ACTION → Jobs, JobItems, JobItemCoats
await Sweep<InAppNotification>(); // NO_ACTION → Customers, Invoices, Quotes
await Sweep<QuotePhoto>(); // NO_ACTION → Quotes
await Sweep<GiftCertificate>(); // NO_ACTION → Customers (GiftCertRedemptions CASCADE)
await Sweep<JobTemplate>(); // NO_ACTION → Customers (JobTemplateItems CASCADE)
await _context.SaveChangesAsync();
// Tier 2 — credit/rework chain (each row blocks the next tier)
await Sweep<CreditMemoApplication>(); // NO_ACTION → Bills, VendorCredits
await Sweep<Refund>(); // NO_ACTION → Invoices, Payments, CreditMemos
await Sweep<CreditMemo>(); // NO_ACTION → Customers, Invoices, ReworkRecords
await _context.SaveChangesAsync();
await Sweep<ReworkRecord>(); // NO_ACTION → Jobs, JobItems (after CreditMemos gone)
await _context.SaveChangesAsync();
// Tier 3 — financial records that block Invoices / Jobs / Quotes
await Sweep<Deposit>(); // NO_ACTION → Jobs, Quotes (CASCADE from Customer anyway)
await Sweep<Payment>(); // NO_ACTION → Invoices
await Sweep<InventoryTransaction>(); // NO_ACTION → Jobs, PurchaseOrders
await _context.SaveChangesAsync();
await Sweep<Invoice>(); // NO_ACTION → Jobs (InvoiceItems, GiftCertRedemptions CASCADE)
await _context.SaveChangesAsync();
// Tier 4 — appointments (NO_ACTION → Customers AND Jobs)
await Sweep<Appointment>();
await _context.SaveChangesAsync();
// Tier 5 — vendor/purchasing chain (BillLineItems.JobId NO_ACTION blocks Jobs)
await Sweep<VendorCreditApplication>(); // NO_ACTION → Bills
await _context.SaveChangesAsync();
await Sweep<Bill>(); // CASCADE → BillLineItems, BillPayments
await Sweep<VendorCredit>(); // CASCADE → VendorCreditLineItems
await Sweep<PurchaseOrder>(); // CASCADE → PurchaseOrderItems
await Sweep<Expense>(); // NO_ACTION → Jobs
await Sweep<OvenBatch>(); // NO_ACTION → Equipment, OvenCosts
await _context.SaveChangesAsync();
// Jobs have a self-referential NO_ACTION FK (OriginalJobId). NULL it before
// deleting so EF doesn't fail on ordering within the same-table batch delete.
await _context.Database.ExecuteSqlRawAsync(
"UPDATE Jobs SET OriginalJobId = NULL WHERE CompanyId = {0}", companyId);
details.Add("✓ Pre-sweep complete: child records cleared in FK-safe order");
}
// ── Customers (+ jobs, quotes, invoices, and all related children) ────
if (options.Customers) if (options.Customers)
{ {
var seededCustomerIds = await _context.Customers var seededCustomerIds = options.ForceRemoveAll
.IgnoreQueryFilters() ? await _context.Customers.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && SeededCustomerEmails.Contains(c.Email)) .Where(c => c.CompanyId == companyId)
.Select(c => c.Id) .Select(c => c.Id).ToListAsync()
.ToListAsync(); : await _context.Customers.IgnoreQueryFilters()
.Where(c => c.CompanyId == companyId && SeededCustomerEmails.Contains(c.Email))
.Select(c => c.Id).ToListAsync();
if (seededCustomerIds.Any()) if (seededCustomerIds.Any())
{ {
// Jobs and their child records var seededJobIds = await _context.Jobs.IgnoreQueryFilters()
var seededJobIds = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && seededCustomerIds.Contains(j.CustomerId)) .Where(j => j.CompanyId == companyId && seededCustomerIds.Contains(j.CustomerId))
.Select(j => j.Id) .Select(j => j.Id).ToListAsync();
.ToListAsync();
var seededQuoteIds = await _context.Quotes.IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && q.CustomerId.HasValue
&& seededCustomerIds.Contains(q.CustomerId.Value))
.Select(q => q.Id).ToListAsync();
// ── Pre-delete records with NO_ACTION FKs pointing at Jobs/Quotes/Customers ──
// For ForceRemoveAll these are already gone (pre-sweep above). For selective
// removal this is the only pass, so we scope to the seeded entity IDs.
// Appointments — NO_ACTION → Customers AND Jobs
if (seededCustomerIds.Any() || seededJobIds.Any())
{
var appts = await _context.Set<Appointment>().IgnoreQueryFilters()
.Where(a => a.CompanyId == companyId
&& (a.CustomerId.HasValue && seededCustomerIds.Contains(a.CustomerId.Value)
|| a.JobId.HasValue && seededJobIds.Contains(a.JobId.Value)))
.ToListAsync();
if (appts.Any()) _context.Set<Appointment>().RemoveRange(appts);
}
// InAppNotifications — NO_ACTION → Customers, Quotes (Invoices handled below)
if (seededCustomerIds.Any() || seededQuoteIds.Any())
{
var nots = await _context.Set<InAppNotification>().IgnoreQueryFilters()
.Where(n => n.CompanyId == companyId
&& (n.CustomerId.HasValue && seededCustomerIds.Contains(n.CustomerId.Value)
|| n.QuoteId.HasValue && seededQuoteIds.Contains(n.QuoteId.Value)))
.ToListAsync();
if (nots.Any()) _context.Set<InAppNotification>().RemoveRange(nots);
}
// QuotePhotos — NO_ACTION → Quotes
if (seededQuoteIds.Any())
{
var qp = await _context.Set<QuotePhoto>().IgnoreQueryFilters()
.Where(p => p.QuoteId.HasValue && seededQuoteIds.Contains(p.QuoteId.Value)).ToListAsync();
if (qp.Any()) _context.Set<QuotePhoto>().RemoveRange(qp);
}
// Deposits — NO_ACTION → Jobs, Quotes (CustomerId is CASCADE from Customer but
// we delete deposits explicitly so jobs/quotes can be deleted first)
if (seededCustomerIds.Any())
{
var deps = await _context.Set<Deposit>().IgnoreQueryFilters()
.Where(d => seededCustomerIds.Contains(d.CustomerId)).ToListAsync();
if (deps.Any()) _context.Set<Deposit>().RemoveRange(deps);
}
await _context.SaveChangesAsync();
// Invoices — NO_ACTION → Jobs AND Customers; must be gone before Jobs are deleted.
// Collect invoice IDs first so Payments (NO_ACTION → Invoices) can be cleared.
List<int> seededInvoiceIds = [];
if (seededCustomerIds.Any())
{
seededInvoiceIds = await _context.Set<Invoice>().IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId
&& seededCustomerIds.Contains(i.CustomerId))
.Select(i => i.Id).ToListAsync();
if (seededInvoiceIds.Any())
{
// InAppNotifications referencing these invoices
var invNots = await _context.Set<InAppNotification>().IgnoreQueryFilters()
.Where(n => n.InvoiceId.HasValue && seededInvoiceIds.Contains(n.InvoiceId.Value))
.ToListAsync();
if (invNots.Any()) _context.Set<InAppNotification>().RemoveRange(invNots);
// Payments — NO_ACTION → Invoices
var pmts = await _context.Set<Payment>().IgnoreQueryFilters()
.Where(p => seededInvoiceIds.Contains(p.InvoiceId)).ToListAsync();
if (pmts.Any()) _context.Set<Payment>().RemoveRange(pmts);
await _context.SaveChangesAsync();
var invoices = await _context.Set<Invoice>().IgnoreQueryFilters()
.Where(i => seededInvoiceIds.Contains(i.Id)).ToListAsync();
if (invoices.Any())
{
_context.Set<Invoice>().RemoveRange(invoices); // InvoiceItems CASCADE
totalRemoved += invoices.Count;
details.Add($"✓ Removed {invoices.Count} invoice(s)");
}
await _context.SaveChangesAsync();
}
}
// ── Jobs and their cascade children ─────────────────────────────────
if (seededJobIds.Any()) if (seededJobIds.Any())
{ {
var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters() var jobPhotos = await _context.JobPhotos.IgnoreQueryFilters()
@@ -155,22 +316,28 @@ public partial class SeedDataService
.Where(p => seededJobIds.Contains(p.JobId)).ToListAsync(); .Where(p => seededJobIds.Contains(p.JobId)).ToListAsync();
if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices); if (jobPrepServices.Any()) _context.JobPrepServices.RemoveRange(jobPrepServices);
var timeEntries = await _context.Set<JobTimeEntry>().IgnoreQueryFilters()
.Where(te => seededJobIds.Contains(te.JobId)).ToListAsync();
if (timeEntries.Any()) _context.Set<JobTimeEntry>().RemoveRange(timeEntries);
var jobs = await _context.Jobs.IgnoreQueryFilters() var jobs = await _context.Jobs.IgnoreQueryFilters()
.Where(j => seededJobIds.Contains(j.Id)).ToListAsync(); .Where(j => seededJobIds.Contains(j.Id)).ToListAsync();
_context.Jobs.RemoveRange(jobs); _context.Jobs.RemoveRange(jobs);
totalRemoved += jobs.Count; totalRemoved += jobs.Count;
details.Add($"✓ Removed {jobs.Count} seeded job(s)"); details.Add($"✓ Removed {jobs.Count} job(s)");
} }
// Quotes and their child records // ── Quotes and their cascade children ───────────────────────────────
var seededQuoteIds = await _context.Quotes
.IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && q.CustomerId.HasValue && seededCustomerIds.Contains(q.CustomerId.Value))
.Select(q => q.Id)
.ToListAsync();
if (seededQuoteIds.Any()) if (seededQuoteIds.Any())
{ {
// Collect AiItemPrediction IDs before removing QuoteItems (FK is NoAction —
// predictions must be orphaned after items are gone, then deleted separately).
var predictionIds = await _context.QuoteItems.IgnoreQueryFilters()
.Where(qi => seededQuoteIds.Contains(qi.QuoteId) && qi.AiPredictionId != null)
.Select(qi => qi.AiPredictionId!.Value)
.Distinct()
.ToListAsync();
var quoteItems = await _context.QuoteItems.IgnoreQueryFilters() var quoteItems = await _context.QuoteItems.IgnoreQueryFilters()
.Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync(); .Where(qi => seededQuoteIds.Contains(qi.QuoteId)).ToListAsync();
if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems); if (quoteItems.Any()) _context.QuoteItems.RemoveRange(quoteItems);
@@ -183,10 +350,24 @@ public partial class SeedDataService
.Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync(); .Where(q => seededQuoteIds.Contains(q.Id)).ToListAsync();
_context.Quotes.RemoveRange(quotes); _context.Quotes.RemoveRange(quotes);
totalRemoved += quotes.Count; totalRemoved += quotes.Count;
details.Add($"✓ Removed {quotes.Count} seeded quote(s)"); details.Add($"✓ Removed {quotes.Count} quote(s)");
if (predictionIds.Any())
{
var predictions = await _context.Set<AiItemPrediction>()
.IgnoreQueryFilters()
.Where(p => predictionIds.Contains(p.Id))
.ToListAsync();
if (predictions.Any())
{
_context.Set<AiItemPrediction>().RemoveRange(predictions);
totalRemoved += predictions.Count;
details.Add($"✓ Removed {predictions.Count} AI prediction(s)");
}
}
} }
// Customer notes // Customer notes (CASCADE from Customer, but explicit for clarity)
var customerNotes = await _context.CustomerNotes.IgnoreQueryFilters() var customerNotes = await _context.CustomerNotes.IgnoreQueryFilters()
.Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync(); .Where(n => seededCustomerIds.Contains(n.CustomerId)).ToListAsync();
if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes); if (customerNotes.Any()) _context.CustomerNotes.RemoveRange(customerNotes);
@@ -195,7 +376,7 @@ public partial class SeedDataService
.Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync(); .Where(c => seededCustomerIds.Contains(c.Id)).ToListAsync();
_context.Customers.RemoveRange(customers); _context.Customers.RemoveRange(customers);
totalRemoved += customers.Count; totalRemoved += customers.Count;
details.Add($"✓ Removed {customers.Count} seeded customer(s)"); details.Add($"✓ Removed {customers.Count} customer(s)");
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
@@ -205,25 +386,44 @@ public partial class SeedDataService
} }
} }
// --- Inventory Items --- // ── Inventory Items ───────────────────────────────────────────────────
if (options.InventoryItems) if (options.InventoryItems)
{ {
var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray(); List<InventoryItem> inventoryItems;
var inventoryItems = await _context.InventoryItems
.IgnoreQueryFilters() if (options.ForceRemoveAll)
.Where(i => i.CompanyId == companyId && seededSkus.Contains(i.SKU)) {
.ToListAsync(); // Full reset: wipe all inventory for this company.
// InventoryTransactions were already cleared in the pre-sweep above.
inventoryItems = await _context.InventoryItems
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId)
.ToListAsync();
}
else
{
// Selective removal: only remove the 11 core seeded SKUs by fingerprint.
var seededSkus = SeededInventorySkuSuffixes.Select(s => $"{company.CompanyCode}{s}").ToArray();
inventoryItems = await _context.InventoryItems
.IgnoreQueryFilters()
.Where(i => i.CompanyId == companyId && seededSkus.Contains(i.SKU))
.ToListAsync();
}
if (inventoryItems.Any()) if (inventoryItems.Any())
{ {
var inventoryIds = inventoryItems.Select(i => i.Id).ToList(); if (!options.ForceRemoveAll)
var transactions = await _context.InventoryTransactions.IgnoreQueryFilters() {
.Where(t => inventoryIds.Contains(t.InventoryItemId)).ToListAsync(); // In selective mode transactions weren't pre-swept — remove them now.
if (transactions.Any()) _context.InventoryTransactions.RemoveRange(transactions); var inventoryIds = inventoryItems.Select(i => i.Id).ToList();
var transactions = await _context.InventoryTransactions.IgnoreQueryFilters()
.Where(t => inventoryIds.Contains(t.InventoryItemId)).ToListAsync();
if (transactions.Any()) _context.InventoryTransactions.RemoveRange(transactions);
}
_context.InventoryItems.RemoveRange(inventoryItems); _context.InventoryItems.RemoveRange(inventoryItems);
totalRemoved += inventoryItems.Count; totalRemoved += inventoryItems.Count;
details.Add($"✓ Removed {inventoryItems.Count} seeded inventory item(s)"); details.Add($"✓ Removed {inventoryItems.Count} inventory item(s)");
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
else else
@@ -232,7 +432,7 @@ public partial class SeedDataService
} }
} }
// --- Equipment (+ maintenance records) --- // ── Equipment (+ maintenance records) ────────────────────────────────
if (options.Equipment) if (options.Equipment)
{ {
var seededEquipment = await _context.Equipment var seededEquipment = await _context.Equipment
@@ -249,7 +449,7 @@ public partial class SeedDataService
_context.Equipment.RemoveRange(seededEquipment); _context.Equipment.RemoveRange(seededEquipment);
totalRemoved += seededEquipment.Count; totalRemoved += seededEquipment.Count;
details.Add($"✓ Removed {seededEquipment.Count} seeded equipment record(s)"); details.Add($"✓ Removed {seededEquipment.Count} equipment record(s)");
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
else else
@@ -258,7 +458,7 @@ public partial class SeedDataService
} }
} }
// --- Catalog Items & Categories --- // ── Catalog Items & Categories ────────────────────────────────────────
if (options.Catalog) if (options.Catalog)
{ {
var seededCategories = await _context.CatalogCategories var seededCategories = await _context.CatalogCategories
@@ -277,12 +477,12 @@ public partial class SeedDataService
{ {
_context.CatalogItems.RemoveRange(catalogItems); _context.CatalogItems.RemoveRange(catalogItems);
totalRemoved += catalogItems.Count; totalRemoved += catalogItems.Count;
details.Add($"✓ Removed {catalogItems.Count} seeded catalog item(s)"); details.Add($"✓ Removed {catalogItems.Count} catalog item(s)");
} }
_context.CatalogCategories.RemoveRange(seededCategories); _context.CatalogCategories.RemoveRange(seededCategories);
totalRemoved += seededCategories.Count; totalRemoved += seededCategories.Count;
details.Add($"✓ Removed {seededCategories.Count} seeded catalog categor(y/ies)"); details.Add($"✓ Removed {seededCategories.Count} catalog categor(y/ies)");
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
else else
@@ -291,7 +491,7 @@ public partial class SeedDataService
} }
} }
// --- Pricing Tiers --- // ── Pricing Tiers ─────────────────────────────────────────────────────
if (options.PricingTiers) if (options.PricingTiers)
{ {
var tiers = await _context.PricingTiers var tiers = await _context.PricingTiers
@@ -303,7 +503,7 @@ public partial class SeedDataService
{ {
_context.PricingTiers.RemoveRange(tiers); _context.PricingTiers.RemoveRange(tiers);
totalRemoved += tiers.Count; totalRemoved += tiers.Count;
details.Add($"✓ Removed {tiers.Count} seeded pricing tier(s)"); details.Add($"✓ Removed {tiers.Count} pricing tier(s)");
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
else else
@@ -312,7 +512,7 @@ public partial class SeedDataService
} }
} }
// --- Operating Costs --- // ── Operating Costs ───────────────────────────────────────────────────
if (options.OperatingCosts) if (options.OperatingCosts)
{ {
var costs = await _context.CompanyOperatingCosts var costs = await _context.CompanyOperatingCosts
@@ -324,7 +524,7 @@ public partial class SeedDataService
{ {
_context.CompanyOperatingCosts.RemoveRange(costs); _context.CompanyOperatingCosts.RemoveRange(costs);
totalRemoved += costs.Count; totalRemoved += costs.Count;
details.Add($"✓ Removed operating costs record"); details.Add("✓ Removed operating costs record");
await _context.SaveChangesAsync(); await _context.SaveChangesAsync();
} }
else else
@@ -333,6 +533,174 @@ public partial class SeedDataService
} }
} }
// ── Purchase Orders ───────────────────────────────────────────────────
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
if (options.Bills && !options.ForceRemoveAll)
{
var poIds = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId)
.Select(p => p.Id)
.ToListAsync();
if (poIds.Any())
{
var poItems = await _context.Set<PurchaseOrderItem>()
.IgnoreQueryFilters()
.Where(i => poIds.Contains(i.PurchaseOrderId))
.ToListAsync();
if (poItems.Any()) _context.Set<PurchaseOrderItem>().RemoveRange(poItems);
var pos = await _context.Set<PurchaseOrder>()
.IgnoreQueryFilters()
.Where(p => poIds.Contains(p.Id))
.ToListAsync();
_context.Set<PurchaseOrder>().RemoveRange(pos);
totalRemoved += pos.Count;
details.Add($"✓ Removed {pos.Count} purchase order(s)");
await _context.SaveChangesAsync();
}
}
// ── Vendor Bills ──────────────────────────────────────────────────────
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
if (options.Bills && !options.ForceRemoveAll)
{
var billIds = await _context.Set<Bill>()
.IgnoreQueryFilters()
.Where(b => b.CompanyId == companyId)
.Select(b => b.Id)
.ToListAsync();
if (billIds.Any())
{
var payments = await _context.Set<BillPayment>()
.IgnoreQueryFilters()
.Where(p => billIds.Contains(p.BillId))
.ToListAsync();
if (payments.Any()) _context.Set<BillPayment>().RemoveRange(payments);
var lineItems = await _context.Set<BillLineItem>()
.IgnoreQueryFilters()
.Where(li => billIds.Contains(li.BillId))
.ToListAsync();
if (lineItems.Any()) _context.Set<BillLineItem>().RemoveRange(lineItems);
var bills = await _context.Set<Bill>()
.IgnoreQueryFilters()
.Where(b => billIds.Contains(b.Id))
.ToListAsync();
_context.Set<Bill>().RemoveRange(bills);
totalRemoved += bills.Count;
details.Add($"✓ Removed {bills.Count} vendor bill(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No vendor bills found");
}
}
// ── Expenses ──────────────────────────────────────────────────────────
// Handled in the pre-sweep for ForceRemoveAll; this block serves selective removal.
if (options.Expenses && !options.ForceRemoveAll)
{
var expenses = await _context.Set<Expense>()
.IgnoreQueryFilters()
.Where(e => e.CompanyId == companyId)
.ToListAsync();
if (expenses.Any())
{
_context.Set<Expense>().RemoveRange(expenses);
totalRemoved += expenses.Count;
details.Add($"✓ Removed {expenses.Count} expense(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No expenses found");
}
}
// ── Vendors ───────────────────────────────────────────────────────────
if (options.Vendors || options.ForceRemoveAll)
{
var vendors = await _context.Set<Vendor>()
.IgnoreQueryFilters()
.Where(v => v.CompanyId == companyId)
.ToListAsync();
if (vendors.Any())
{
_context.Set<Vendor>().RemoveRange(vendors);
totalRemoved += vendors.Count;
details.Add($"✓ Removed {vendors.Count} vendor(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No vendors found");
}
}
// ── Named Ovens (OvenCost) ────────────────────────────────────────────
if (options.NamedOvens || options.ForceRemoveAll)
{
var ovens = await _context.Set<OvenCost>()
.IgnoreQueryFilters()
.Where(o => o.CompanyId == companyId)
.ToListAsync();
if (ovens.Any())
{
_context.Set<OvenCost>().RemoveRange(ovens);
totalRemoved += ovens.Count;
details.Add($"✓ Removed {ovens.Count} named oven(s)");
await _context.SaveChangesAsync();
}
else
{
details.Add("• No named ovens found");
}
}
// ── Shop Workers ──────────────────────────────────────────────────────
if (options.Workers)
{
var workerUsers = options.ForceRemoveAll
? await _userManager.Users
.Where(u => u.CompanyId == companyId && u.CompanyRole == "Worker")
.ToListAsync()
: await _userManager.Users
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == companyId)
.ToListAsync();
if (workerUsers.Any())
{
// EmployeeClockEntry has DeleteBehavior.Restrict on UserId — delete
// clock entries first so the user delete succeeds without a FK violation.
var workerUserIds = workerUsers.Select(u => u.Id).ToList();
var clockEntries = await _context.Set<EmployeeClockEntry>().IgnoreQueryFilters()
.Where(e => workerUserIds.Contains(e.UserId)).ToListAsync();
if (clockEntries.Any())
{
_context.Set<EmployeeClockEntry>().RemoveRange(clockEntries);
await _context.SaveChangesAsync();
details.Add($"✓ Removed {clockEntries.Count} clock entry/entries");
}
foreach (var wu in workerUsers)
await _userManager.DeleteAsync(wu);
totalRemoved += workerUsers.Count;
details.Add($"✓ Removed {workerUsers.Count} demo shop worker(s)");
}
else
{
details.Add("• No demo shop workers found");
}
}
result.ItemsSeeded = totalRemoved; result.ItemsSeeded = totalRemoved;
result.Details = details; result.Details = details;
result.Message = totalRemoved > 0 result.Message = totalRemoved > 0
@@ -0,0 +1,194 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Infrastructure.Services;
public partial class SeedDataService
{
/// <summary>
/// Canonical emails of the 3 demo employees (2 workers + 1 manager). Used as fingerprints
/// in RemoveSeedDataAsync to avoid needing a special "IsSeeded" flag on ApplicationUser.
/// </summary>
internal static readonly string[] SeededWorkerEmails =
[
"mike.sanders@pcldemo.com",
"jake.wilson@pcldemo.com",
"sarah.brooks@pcldemo.com",
];
/// <summary>
/// Seeds 3 named employees as ApplicationUser records for the demo company:
/// Mike Sanders (Coater, Worker), Jake Wilson (Sandblaster, Worker),
/// and Sarah Brooks (Shop Manager, Manager role with broader permissions).
/// </summary>
/// <remarks>
/// Workers are seeded before jobs and time entries so that AssignedUserId on Job
/// and UserId on JobTimeEntry and EmployeeClockEntry can reference them.
/// Uses @pcldemo.com email domain — will never conflict with real accounts.
/// Idempotency: bails early if any of the 3 emails already exist for this company.
/// </remarks>
private async Task<int> SeedShopWorkersAsync(Company company)
{
var anyExists = await _userManager.Users
.AnyAsync(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id);
if (anyExists) return 0;
const string pwd = "Worker123!Demo";
var hireDate = DateTime.UtcNow.AddMonths(-18);
int created = 0;
// ── 2 shop workers ────────────────────────────────────────────────────────
foreach (var (email, fn, ln, emp, pos, rate) in new (string, string, string, string, string, decimal)[]
{
("mike.sanders@pcldemo.com", "Mike", "Sanders", "EMP-001", "Coater", 22.00m),
("jake.wilson@pcldemo.com", "Jake", "Wilson", "EMP-002", "Sandblaster", 20.00m),
})
{
if (await _userManager.FindByEmailAsync(email) != null) continue;
var user = new ApplicationUser
{
UserName = email, Email = email,
FirstName = fn, LastName = ln,
EmployeeNumber = emp, Position = pos,
Department = "Shop Floor",
LaborCostPerHour = rate,
EmailConfirmed = true, IsActive = true,
HireDate = hireDate,
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.Worker,
CanManageJobs = true,
CanViewShopFloor = true,
CreatedAt = hireDate
};
var r = await _userManager.CreateAsync(user, pwd);
if (!r.Succeeded)
throw new InvalidOperationException(
$"Failed to create {email}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
await _userManager.AddToRoleAsync(user, AppConstants.Roles.Employee);
created++;
}
// ── 1 manager ─────────────────────────────────────────────────────────────
const string managerEmail = "sarah.brooks@pcldemo.com";
if (await _userManager.FindByEmailAsync(managerEmail) == null)
{
var mgr = new ApplicationUser
{
UserName = managerEmail, Email = managerEmail,
FirstName = "Sarah", LastName = "Brooks",
EmployeeNumber = "EMP-003", Position = "Shop Manager",
Department = "Management",
LaborCostPerHour = 32.00m,
EmailConfirmed = true, IsActive = true,
HireDate = hireDate.AddMonths(-6), // hired before the workers
CompanyId = company.Id,
CompanyRole = AppConstants.CompanyRoles.Manager,
CanManageJobs = true, CanViewShopFloor = true,
CanManageCustomers = true, CanCreateQuotes = true,
CanApproveQuotes = true, CanManageCalendar = true,
CanViewCalendar = true, CanManageProducts = true,
CanViewProducts = true, CanManageEquipment = true,
CanManageMaintenance = true, CanManageInventory = true,
CanViewReports = true,
CreatedAt = hireDate.AddMonths(-6)
};
var r = await _userManager.CreateAsync(mgr, pwd);
if (!r.Succeeded)
throw new InvalidOperationException(
$"Failed to create {managerEmail}: {string.Join("; ", r.Errors.Select(e => e.Description))}");
await _userManager.AddToRoleAsync(mgr, AppConstants.Roles.Employee);
created++;
}
return created;
}
/// <summary>
/// Seeds job time entries for completed and in-progress jobs, giving the Worker
/// Productivity report meaningful data from day one.
/// </summary>
/// <remarks>
/// Each completed or in-progress job receives 24 time entries spread across the
/// 5 demo workers, with realistic hours for each coating stage (sandblasting,
/// masking, coating, curing, inspection). The total hours roughly correlate with
/// the job's EstimatedMinutes from its first JobItem.
///
/// Idempotency: bails early if any time entries already exist for this company's jobs.
/// </remarks>
private async Task<int> SeedJobTimeEntriesAsync(Company company)
{
var existingCount = await _context.Set<JobTimeEntry>()
.IgnoreQueryFilters()
.CountAsync(te => te.CompanyId == company.Id && !te.IsDeleted);
if (existingCount > 0) return 0;
var workers = await _userManager.Users
.Where(u => SeededWorkerEmails.Contains(u.Email) && u.CompanyId == company.Id)
.OrderBy(u => u.Email)
.ToListAsync();
if (workers.Count == 0) return 0;
// Resolve status IDs first — avoids relying on Include(j => j.JobStatus) which can
// silently return null navigation properties when query filters interact with IgnoreQueryFilters.
var workedStatusIds = await _context.Set<JobStatusLookup>()
.IgnoreQueryFilters()
.Where(s => s.CompanyId == company.Id && new[]
{
"IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING",
"IN_OVEN", "COATING", "CURING", "QUALITY_CHECK",
"COMPLETED", "READY_FOR_PICKUP", "DELIVERED"
}.Contains(s.StatusCode))
.Select(s => s.Id)
.ToListAsync();
if (workedStatusIds.Count == 0) return 0;
var workedJobs = await _context.Set<Job>()
.IgnoreQueryFilters()
.Where(j => j.CompanyId == company.Id && !j.IsDeleted
&& workedStatusIds.Contains(j.JobStatusId))
.ToListAsync();
if (workedJobs.Count == 0) return 0;
string[] stages = ["Sandblasting", "Masking & Prep", "Coating", "Curing", "Inspection"];
decimal[] stageHours = [1.5m, 0.75m, 1.25m, 0.5m, 0.5m];
var entries = new List<JobTimeEntry>();
int jobIdx = 0;
foreach (var job in workedJobs)
{
// 2-4 entries per job cycling through stages
var entryCount = 2 + (jobIdx % 3);
var workDate = (job.StartedDate ?? job.CreatedAt).AddDays(1);
for (int e = 0; e < entryCount; e++)
{
var worker = workers[(jobIdx + e) % workers.Count];
var stageIdx = e % stages.Length;
entries.Add(new JobTimeEntry
{
JobId = job.Id,
UserId = worker.Id,
UserDisplayName = worker.FullName,
WorkDate = workDate.AddDays(e),
HoursWorked = stageHours[stageIdx],
Stage = stages[stageIdx],
CompanyId = company.Id,
CreatedAt = workDate.AddDays(e)
});
}
jobIdx++;
}
await _context.Set<JobTimeEntry>().AddRangeAsync(entries);
await _context.SaveChangesAsync();
return entries.Count;
}
}
@@ -3,6 +3,7 @@ using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces; using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data; using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
@@ -13,15 +14,18 @@ public partial class SeedDataService : ISeedDataService
private readonly ApplicationDbContext _context; private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly RoleManager<IdentityRole> _roleManager; private readonly RoleManager<IdentityRole> _roleManager;
private readonly IAccountBalanceService _accountBalanceService;
public SeedDataService( public SeedDataService(
ApplicationDbContext context, ApplicationDbContext context,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
RoleManager<IdentityRole> roleManager) RoleManager<IdentityRole> roleManager,
IAccountBalanceService accountBalanceService)
{ {
_context = context; _context = context;
_userManager = userManager; _userManager = userManager;
_roleManager = roleManager; _roleManager = roleManager;
_accountBalanceService = accountBalanceService;
} }
/// <summary> /// <summary>
@@ -411,18 +415,75 @@ public partial class SeedDataService : ISeedDataService
} }
catch (Exception ex) { errors.Add($"✗ Customers: {ex.Message}"); _context.ChangeTracker.Clear(); } catch (Exception ex) { errors.Add($"✗ Customers: {ex.Message}"); _context.ChangeTracker.Clear(); }
// Workers must be seeded before jobs so AssignedUserId FK resolves
await RunSeeder("Shop workers", details, errors, result, () => SeedShopWorkersAsync(company));
await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company)); await RunSeeder("Equipment", details, errors, result, () => SeedEquipmentAsync(company));
await RunSeeder("Maintenance", details, errors, result, () => SeedMaintenanceRecordsAsync(company));
await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(company)); await RunSeeder("Vendors", details, errors, result, () => SeedVendorsAsync(company));
await RunSeeder("Purchase orders", details, errors, result, () => SeedPurchaseOrdersAsync(company));
await RunSeeder("Named ovens", details, errors, result, () => SeedOvenCostsAsync(company)); await RunSeeder("Named ovens", details, errors, result, () => SeedOvenCostsAsync(company));
await RunSeeder("Catalog", details, errors, result, () => SeedCatalogAsync(company)); await RunSeeder("Catalog", details, errors, result, () => SeedCatalogAsync(company));
await RunSeeder("Quotes", details, errors, result, () => SeedQuotesAsync(company)); await RunSeeder("Quotes", details, errors, result, () => SeedQuotesAsync(company));
await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company)); await RunSeeder("Jobs", details, errors, result, () => SeedJobsAsync(company));
await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company)); await RunSeeder("Job history", details, errors, result, () => SeedJobStatusHistoryAsync(company));
await RunSeeder("Time entries", details, errors, result, () => SeedJobTimeEntriesAsync(company));
await RunSeeder("Clock entries", details, errors, result, () => SeedEmployeeClockEntriesAsync(company));
await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company)); await RunSeeder("Inv. txns", details, errors, result, () => SeedInventoryTransactionsAsync(company));
await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company)); await RunSeeder("Invoices", details, errors, result, () => SeedInvoicesAsync(company));
await RunSeeder("AI predictions", details, errors, result, () => SeedAiPredictionsAsync(company));
// Ensure chart of accounts exists before bills/expenses — both seeders silently return 0
// if the AP or checking account is missing. SeedDefaultChartOfAccountsAsync is idempotent.
try
{
var accountsAdded = await SeedDefaultChartOfAccountsAsync(company);
var systemAccountsAdded = await EnsureSystemAccountsAsync(company);
if (accountsAdded > 0)
details.Add($"✓ {accountsAdded} chart of account(s) created");
if (systemAccountsAdded > 0)
details.Add($"✓ {systemAccountsAdded} missing system account(s) added");
}
catch (Exception ex) { errors.Add($"✗ Chart of accounts: {ex.Message}"); _context.ChangeTracker.Clear(); }
await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company)); await RunSeeder("Vendor bills", details, errors, result, () => SeedBillsAsync(company));
await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company)); await RunSeeder("Expenses", details, errors, result, () => SeedExpensesAsync(company));
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
// Accounts survive resets (no removal sweep), so the chart-of-accounts seeder skips them
// on every reset after the first. But 12 months of seeded expenses outpace ~3 months of
// seeded revenue, and without a prior-period cash balance the checking account shows a
// large negative. Patch the opening balances unconditionally so every reset is realistic.
try
{
var checkingAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Checking);
if (checkingAcct != null && checkingAcct.OpeningBalance == 0)
{
checkingAcct.OpeningBalance = 75_000m;
checkingAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
checkingAcct.CurrentBalance = 75_000m;
await _context.SaveChangesAsync();
details.Add("✓ Checking account opening balance set to $75,000");
}
var savingsAcct = await _context.Set<Account>().IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && !a.IsDeleted
&& a.AccountSubType == AccountSubType.Savings);
if (savingsAcct != null && savingsAcct.OpeningBalance == 0)
{
savingsAcct.OpeningBalance = 14_500m;
savingsAcct.OpeningBalanceDate = DateTime.UtcNow.AddYears(-1);
savingsAcct.CurrentBalance = 14_500m;
await _context.SaveChangesAsync();
details.Add("✓ Savings account opening balance set to $14,500");
}
}
catch (Exception ex) { errors.Add($"✗ Account opening balances: {ex.Message}"); _context.ChangeTracker.Clear(); }
await RunSeeder("Appointments", details, errors, result, () => SeedAppointmentsAsync(company));
await RunSeeder("Job notes", details, errors, result, () => SeedJobNotesAsync(company));
await RunSeeder("Customer notes", details, errors, result, () => SeedCustomerNotesAsync(company));
await RunSeeder("Rework records", details, errors, result, () => SeedReworkRecordsAsync(company));
await RunSeeder("Deposits", details, errors, result, () => SeedDepositsAsync(company));
await RunSeeder("Bank recon", details, errors, result, () => SeedBankReconciliationsAsync(company));
if (company.CompanyCode == "DEMO") if (company.CompanyCode == "DEMO")
{ {
@@ -437,6 +498,15 @@ public partial class SeedDataService : ISeedDataService
catch (Exception ex) { errors.Add($"✗ Demo users: {ex.Message}"); _context.ChangeTracker.Clear(); } catch (Exception ex) { errors.Add($"✗ Demo users: {ex.Message}"); _context.ChangeTracker.Clear(); }
} }
// Replay all GL transactions so CurrentBalance reflects the full seeded history,
// including the opening balances patched above.
try
{
await _accountBalanceService.RecalculateAllAsync(company.Id);
details.Add("✓ Account balances recalculated");
}
catch (Exception ex) { errors.Add($"✗ Account balance recalculation: {ex.Message}"); _context.ChangeTracker.Clear(); }
if (errors.Any()) if (errors.Any())
{ {
details.AddRange(errors); details.AddRange(errors);
@@ -742,325 +812,279 @@ public partial class SeedDataService : ISeedDataService
} }
/// <summary> /// <summary>
/// Seeds ten representative inventory items (eight powder colours, one cleaner, one /// Seeds 11 inventory items (6 powder colours + 5 consumables) for the company.
/// masking tape roll) for the company, linking each to the appropriate category lookup. /// Two powders are intentionally below reorder point (low-stock alert) and one
/// consumable is at zero (out-of-stock), matching the demo company spec.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Returns a tuple rather than a plain int because each item is saved individually /// Powders are the six colours featured in the demo company's jobs and quotes:
/// (one <c>SaveChangesAsync</c> call per item) so that a duplicate-SKU error on one /// Gloss Black, Matte Black, Super Chrome (low), Candy Red (low), Signal White,
/// item does not roll back the entire batch. Failed items are captured as per-item /// Illusion Purple. Consumables are the five shop supplies shown in tutorials:
/// warning strings rather than aborting the seeder. /// Masking Tape, Silicone Plugs (out-of-stock), Hanging Hooks, Acetone, Blast Media.
/// ///
/// SKUs are prefixed with the company's <see cref="Company.CompanyCode"/> to guarantee /// SKUs are prefixed with <see cref="Company.CompanyCode"/> to guarantee uniqueness
/// uniqueness across tenants in a shared database e.g., <c>DEMO-PWD-BLK-001</c>. /// across tenants in a shared database (e.g., DEMO-PWD-GBK-001).
/// A missing or empty CompanyCode throws <see cref="InvalidOperationException"/> because
/// SKU collisions would violate the unique index on the InventoryItems table.
///
/// Category IDs are resolved by <c>CategoryCode</c> (e.g., "POWDER", "CLEANER") rather
/// than hard-coded IDs because lookup IDs differ per company and per environment.
///
/// All powder items default to <c>CoverageSqFtPerLb = 30</c> and
/// <c>TransferEfficiency = 65</c>, which are industry-standard starting values used by
/// the pricing engine when calculating powder needed per coat.
/// </remarks> /// </remarks>
/// <param name="company">The tenant company to seed inventory for.</param>
/// <returns>
/// A tuple of (count of items successfully inserted, list of per-item warning messages
/// for skipped or failed items).
/// </returns>
private async Task<(int seededCount, List<string> warnings)> SeedInventoryItemsAsync(Company company) private async Task<(int seededCount, List<string> warnings)> SeedInventoryItemsAsync(Company company)
{ {
var warnings = new List<string>(); var warnings = new List<string>();
int seededCount = 0; int seededCount = 0;
// Validate company code
if (string.IsNullOrWhiteSpace(company.CompanyCode)) if (string.IsNullOrWhiteSpace(company.CompanyCode))
{ throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode.");
throw new InvalidOperationException($"Company {company.CompanyName} (ID: {company.Id}) has no CompanyCode. Cannot seed inventory with unique SKUs.");
}
var skuPrefix = company.CompanyCode; var skuPrefix = company.CompanyCode;
// Get category lookups to link items properly
var categories = await _context.InventoryCategoryLookups var categories = await _context.InventoryCategoryLookups
.IgnoreQueryFilters() .IgnoreQueryFilters()
.Where(c => c.CompanyId == company.Id && !c.IsDeleted) .Where(c => c.CompanyId == company.Id && !c.IsDeleted)
.ToListAsync(); .ToListAsync();
var powderCategory = categories.FirstOrDefault(c => c.CategoryCode == "POWDER"); var powderCat = categories.FirstOrDefault(c => c.CategoryCode == "POWDER");
var cleanerCategory = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER"); var cleanerCat = categories.FirstOrDefault(c => c.CategoryCode == "CLEANER");
var maskingCategory = categories.FirstOrDefault(c => c.CategoryCode == "MASKING"); var maskingCat = categories.FirstOrDefault(c => c.CategoryCode == "MASKING");
var abrasiveCat = categories.FirstOrDefault(c => c.CategoryCode == "ABRASIVE");
var consumeCat = categories.FirstOrDefault(c => c.CategoryCode == "CONSUMABLE");
// Use company code prefix to ensure unique SKUs across companies // ── Helper: powder item ───────────────────────────────────────────────
InventoryItem Pwd(string sku, string name, string color, string ral, string finish,
string mfr, string mfrPn, int qty, int reorder, int reorderQty, decimal cost) =>
new InventoryItem
{
SKU = $"{skuPrefix}-{sku}", Name = name, Description = $"{finish} {color} powder coating",
Category = "Powder", InventoryCategoryId = powderCat?.Id,
ColorName = color, ColorCode = ral, Finish = finish,
Manufacturer = mfr, ManufacturerPartNumber = mfrPn,
QuantityOnHand = qty, UnitOfMeasure = "lbs",
ReorderPoint = reorder, ReorderQuantity = reorderQty,
MinimumStock = reorder / 2, MaximumStock = reorderQty * 4,
UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost,
LastPurchaseDate = DateTime.UtcNow.AddDays(-30),
CoverageSqFtPerLb = 30m, TransferEfficiency = 65m,
IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow
};
// ── Helper: supply/consumable item ────────────────────────────────────
InventoryItem Supply(string sku, string name, string desc, string cat,
int? catId, string uom, int qty, int reorder, int reorderQty, decimal cost) =>
new InventoryItem
{
SKU = $"{skuPrefix}-{sku}", Name = name, Description = desc,
Category = cat, InventoryCategoryId = catId,
QuantityOnHand = qty, UnitOfMeasure = uom,
ReorderPoint = reorder, ReorderQuantity = reorderQty,
MinimumStock = reorder / 2, MaximumStock = reorderQty * 4,
UnitCost = cost, AverageCost = cost, LastPurchasePrice = cost,
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
IsActive = true, CompanyId = company.Id, CreatedAt = DateTime.UtcNow
};
// ── 6 Powders (2 low-stock, 0 out-of-stock) ──────────────────────────
// Super Chrome (40 lbs) and Candy Red (25 lbs) are below reorder point
// so the dashboard low-stock alert card is populated on first load.
var inventoryItems = new List<InventoryItem> var inventoryItems = new List<InventoryItem>
{ {
new InventoryItem Pwd("PWD-GBK-001", "Gloss Black", "Gloss Black", "RAL 9005", "Gloss", "Prismatic Powders", "PP-GBK-001", 300, 80, 200, 4.50m),
{ Pwd("PWD-MBK-001", "Matte Black", "Matte Black", "RAL 9005", "Matte", "Prismatic Powders", "PP-MBK-001", 500, 100, 250, 4.50m),
SKU = $"{skuPrefix}-PWD-BLK-001", Pwd("PWD-CHR-001", "Super Chrome", "Super Chrome", "RAL 9006", "Chrome", "Columbia Coatings", "CC-CHR-001", 40, 100, 150, 8.75m), // LOW STOCK
Name = "Matte Black Powder", Pwd("PWD-CRD-001", "Candy Red", "Candy Red", "RAL 3028", "Candy", "Prismatic Powders", "PP-CRD-001", 25, 50, 100, 6.50m), // LOW STOCK
Description = "High-quality matte black powder coating", Pwd("PWD-SWH-001", "Signal White", "Signal White", "RAL 9003", "Gloss", "Columbia Coatings", "CC-SWH-001", 400, 80, 200, 4.25m),
Category = "Powder", Pwd("PWD-IPU-001", "Illusion Purple","Illusion Purple","RAL 4005", "Metallic", "Prismatic Powders", "PP-IPU-001", 150, 60, 120, 7.25m),
InventoryCategoryId = powderCategory?.Id,
ColorName = "Matte Black", // ── 5 Consumables (1 out-of-stock) ───────────────────────────────
ColorCode = "RAL 9005", // Silicone Plugs at qty=0 so the dashboard shows one out-of-stock item.
Finish = "Matte",
Manufacturer = "Tiger Drylac", Supply("MSK-001", "High-Temp Masking Tape", "2-inch heat-resistant masking tape", "Masking Supplies", maskingCat?.Id, "rolls", 80, 30, 100, 8.75m),
ManufacturerPartNumber = "TG-MB-001", Supply("PLG-001", "Silicone Plugs Assorted", "Assorted silicone masking plugs (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 0, 50, 100, 14.50m), // OUT OF STOCK
QuantityOnHand = 500, Supply("HKS-001", "Powder Coating Hooks", "Steel hanging hooks for racking parts", "Consumables", consumeCat?.Id, "count", 200, 50, 200, 0.35m),
UnitOfMeasure = "lbs", Supply("ACT-001", "Acetone Degreaser", "Industrial acetone for pre-coating degreasing", "Cleaner", cleanerCat?.Id, "gallons", 20, 5, 25, 18.00m),
ReorderPoint = 100, Supply("BLM-001", "Aluminum Oxide Blast Media","120-grit aluminum oxide blasting media", "Abrasive Media", abrasiveCat?.Id, "lbs", 250, 100, 250, 1.85m),
ReorderQuantity = 250,
MinimumStock = 50, // ── Additional Prismatic Powders ──────────────────────────────────────
MaximumStock = 1000, Pwd("PWD-AWH-001", "Arctic White", "Arctic White", "RAL 9003", "Gloss", "Prismatic Powders", "PP-AWH-001", 400, 100, 250, 4.25m),
UnitCost = 4.50m, Pwd("PWD-PUW-001", "Pure White", "Pure White", "RAL 9010", "Gloss", "Prismatic Powders", "PP-PUW-001", 350, 80, 200, 4.25m),
AverageCost = 4.50m, Pwd("PWD-CRM-001", "Cream", "Cream", "RAL 9001", "Gloss", "Prismatic Powders", "PP-CRM-001", 200, 50, 150, 4.50m),
LastPurchasePrice = 4.50m, Pwd("PWD-LIV-001", "Light Ivory", "Light Ivory", "RAL 1015", "Gloss", "Prismatic Powders", "PP-LIV-001", 150, 40, 100, 4.50m),
LastPurchaseDate = DateTime.UtcNow.AddDays(-30), Pwd("PWD-STK-001", "Satin Black", "Satin Black", "RAL 9005", "Satin", "Prismatic Powders", "PP-STK-001", 250, 80, 200, 4.75m),
CoverageSqFtPerLb = 30m, Pwd("PWD-FLK-001", "Flat Black", "Flat Black", "RAL 9005", "Flat", "Prismatic Powders", "PP-FLK-001", 200, 60, 150, 4.50m),
TransferEfficiency = 65m, Pwd("PWD-TXK-001", "Texture Black", "Texture Black", "RAL 9005", "Texture", "Prismatic Powders", "PP-TXK-001", 175, 50, 150, 5.25m),
IsActive = true, Pwd("PWD-WRK-001", "Wrinkle Black", "Wrinkle Black", "RAL 9005", "Texture", "Prismatic Powders", "PP-WRK-001", 180, 50, 150, 5.50m),
CompanyId = company.Id, Pwd("PWD-HMK-001", "Hammertone Black", "Hammertone Black", "RAL 9005", "Texture", "Prismatic Powders", "PP-HMK-001", 120, 40, 120, 5.75m),
CreatedAt = DateTime.UtcNow Pwd("PWD-GLR-001", "Gloss Red", "Gloss Red", "RAL 3000", "Gloss", "Prismatic Powders", "PP-GLR-001", 180, 60, 150, 4.75m),
}, Pwd("PWD-WNR-001", "Wine Red", "Wine Red", "RAL 3005", "Gloss", "Prismatic Powders", "PP-WNR-001", 120, 40, 100, 4.75m),
new InventoryItem Pwd("PWD-STR-001", "Satin Red", "Satin Red", "RAL 3000", "Satin", "Prismatic Powders", "PP-STR-001", 150, 50, 120, 5.00m),
{ Pwd("PWD-TXR-001", "Wrinkle Red", "Wrinkle Red", "RAL 3000", "Texture", "Prismatic Powders", "PP-TXR-001", 80, 30, 80, 5.50m),
SKU = $"{skuPrefix}-PWD-WHT-001", Pwd("PWD-GLB-001", "Gloss Blue", "Gloss Blue", "RAL 5005", "Gloss", "Prismatic Powders", "PP-GLB-001", 180, 60, 150, 4.75m),
Name = "Gloss White Powder", Pwd("PWD-SKB-001", "Sky Blue", "Sky Blue", "RAL 5015", "Gloss", "Prismatic Powders", "PP-SKB-001", 150, 50, 120, 4.75m),
Description = "High-gloss white powder coating", Pwd("PWD-MTB-001", "Matte Blue", "Matte Blue", "RAL 5005", "Matte", "Prismatic Powders", "PP-MTB-001", 120, 40, 100, 4.75m),
Category = "Powder", Pwd("PWD-NVB-001", "Navy Blue", "Navy Blue", "RAL 5003", "Gloss", "Prismatic Powders", "PP-NVB-001", 200, 60, 150, 4.75m),
InventoryCategoryId = powderCategory?.Id, Pwd("PWD-CNB-001", "Candy Blue", "Candy Blue", "RAL 5005", "Candy", "Prismatic Powders", "PP-CNB-001", 60, 25, 75, 7.50m),
ColorName = "Gloss White", Pwd("PWD-GXB-001", "Galaxy Blue Metallic", "Galaxy Blue", "RAL 5005", "Metallic", "Prismatic Powders", "PP-GXB-001", 75, 25, 75, 8.25m),
ColorCode = "RAL 9010", Pwd("PWD-CBM-001", "Cobalt Blue Metallic", "Cobalt Blue", "RAL 5013", "Metallic", "Prismatic Powders", "PP-CBM-001", 80, 30, 80, 8.00m),
Finish = "Gloss", Pwd("PWD-GLG-001", "Gloss Green", "Gloss Green", "RAL 6002", "Gloss", "Prismatic Powders", "PP-GLG-001", 150, 50, 120, 4.75m),
Manufacturer = "Tiger Drylac", Pwd("PWD-MTG-001", "Matte Green", "Matte Green", "RAL 6002", "Matte", "Prismatic Powders", "PP-MTG-001", 100, 40, 100, 4.75m),
ManufacturerPartNumber = "TG-GW-001", Pwd("PWD-HNG-001", "Hunter Green", "Hunter Green", "RAL 6005", "Matte", "Prismatic Powders", "PP-HNG-001", 120, 40, 100, 4.75m),
QuantityOnHand = 400, Pwd("PWD-LMG-001", "Lime Green", "Lime Green", "RAL 6018", "Gloss", "Prismatic Powders", "PP-LMG-001", 80, 30, 80, 5.00m),
UnitOfMeasure = "lbs", Pwd("PWD-ODG-001", "OD Green", "OD Green", "RAL 6014", "Flat", "Prismatic Powders", "PP-ODG-001", 100, 35, 90, 4.75m),
ReorderPoint = 100, Pwd("PWD-TLG-001", "Teal", "Teal", "RAL 5018", "Matte", "Prismatic Powders", "PP-TLG-001", 90, 30, 80, 5.00m),
ReorderQuantity = 250, Pwd("PWD-SYL-001", "Safety Yellow", "Safety Yellow", "RAL 1023", "Gloss", "Prismatic Powders", "PP-SYL-001", 150, 50, 120, 5.00m),
MinimumStock = 50, Pwd("PWD-JDY-001", "John Deere Yellow", "John Deere Yellow", "RAL 1021", "Gloss", "Prismatic Powders", "PP-JDY-001", 100, 35, 90, 5.00m),
MaximumStock = 1000, Pwd("PWD-SOR-001", "Safety Orange", "Safety Orange", "RAL 2004", "Gloss", "Prismatic Powders", "PP-SOR-001", 120, 40, 100, 5.00m),
UnitCost = 4.25m, Pwd("PWD-CGR-001", "Charcoal", "Charcoal", "RAL 7016", "Matte", "Prismatic Powders", "PP-CGR-001", 300, 80, 200, 4.75m),
AverageCost = 4.25m, Pwd("PWD-ALV-001", "Aluminum Silver", "Aluminum Silver", "RAL 9006", "Gloss", "Prismatic Powders", "PP-ALV-001", 200, 60, 150, 4.75m),
LastPurchasePrice = 4.25m, Pwd("PWD-SLG-001", "Slate Gray", "Slate Gray", "RAL 7035", "Matte", "Prismatic Powders", "PP-SLG-001", 180, 60, 150, 4.75m),
LastPurchaseDate = DateTime.UtcNow.AddDays(-25), Pwd("PWD-GRF-001", "Graphite", "Graphite", "RAL 7024", "Satin", "Prismatic Powders", "PP-GRF-001", 150, 50, 120, 5.00m),
CoverageSqFtPerLb = 30m, Pwd("PWD-TFG-001", "Traffic Gray", "Traffic Gray", "RAL 7042", "Matte", "Prismatic Powders", "PP-TFG-001", 120, 40, 100, 4.75m),
TransferEfficiency = 65m, Pwd("PWD-GNM-001", "Gunmetal", "Gunmetal", "RAL 7016", "Metallic", "Prismatic Powders", "PP-GNM-001", 100, 35, 90, 7.00m),
IsActive = true, Pwd("PWD-HMS-001", "Hammertone Silver", "Hammertone Silver", "RAL 9006", "Texture", "Prismatic Powders", "PP-HMS-001", 120, 40, 100, 5.75m),
CompanyId = company.Id, Pwd("PWD-HMB-001", "Hammertone Bronze", "Hammertone Bronze", "RAL 8019", "Texture", "Prismatic Powders", "PP-HMB-001", 95, 35, 90, 5.75m),
CreatedAt = DateTime.UtcNow Pwd("PWD-FLO-001", "Fluorescent Orange", "Fluorescent Orange", "RAL 2009", "Fluorescent", "Prismatic Powders", "PP-FLO-001", 50, 20, 60, 9.50m),
}, Pwd("PWD-FLY-001", "Fluorescent Yellow", "Fluorescent Yellow", "RAL 1026", "Fluorescent", "Prismatic Powders", "PP-FLY-001", 50, 20, 60, 9.50m),
new InventoryItem Pwd("PWD-GLD-001", "Gold Metallic", "Gold", "RAL 1036", "Metallic", "Prismatic Powders", "PP-GLD-001", 80, 30, 80, 8.75m),
{ Pwd("PWD-CPM-001", "Copper Metallic", "Copper", "RAL 2012", "Metallic", "Prismatic Powders", "PP-CPM-001", 75, 25, 75, 8.25m),
SKU = $"{skuPrefix}-PWD-RED-001", Pwd("PWD-RSG-001", "Rose Gold Metallic", "Rose Gold", "RAL 3012", "Metallic", "Prismatic Powders", "PP-RSG-001", 60, 20, 60, 8.75m),
Name = "Gloss Red Powder", Pwd("PWD-PNY-001", "Penny Copper Metallic", "Penny Copper", "RAL 8023", "Metallic", "Prismatic Powders", "PP-PNY-001", 55, 20, 60, 8.50m),
Description = "Vibrant gloss red powder coating", Pwd("PWD-BRZ-001", "Bronze Metallic", "Bronze", "RAL 8019", "Metallic", "Prismatic Powders", "PP-BRZ-001", 90, 30, 80, 7.50m),
Category = "Powder", Pwd("PWD-ANB-001", "Anodized Bronze", "Anodized Bronze", "RAL 8019", "Metallic", "Prismatic Powders", "PP-ANB-001", 70, 25, 70, 8.00m),
InventoryCategoryId = powderCategory?.Id, Pwd("PWD-ANK-001", "Anodized Black", "Anodized Black", "RAL 9005", "Metallic", "Prismatic Powders", "PP-ANK-001", 85, 30, 80, 7.75m),
ColorName = "Traffic Red", Pwd("PWD-CHP-001", "Champagne", "Champagne", "RAL 1019", "Satin", "Prismatic Powders", "PP-CHP-001", 100, 35, 90, 5.25m),
ColorCode = "RAL 3020", Pwd("PWD-DES-001", "Desert Tan", "Desert Tan", "RAL 1001", "Matte", "Prismatic Powders", "PP-DES-001", 120, 40, 100, 4.75m),
Finish = "Gloss", Pwd("PWD-ESP-001", "Espresso Brown", "Espresso Brown", "RAL 8016", "Matte", "Prismatic Powders", "PP-ESP-001", 80, 30, 80, 5.00m),
Manufacturer = "Tiger Drylac", Pwd("PWD-MOC-001", "Mocha Brown", "Mocha Brown", "RAL 8025", "Satin", "Prismatic Powders", "PP-MOC-001", 70, 25, 70, 5.25m),
ManufacturerPartNumber = "TG-GR-001", Pwd("PWD-BGN-001", "Burgundy", "Burgundy", "RAL 3032", "Matte", "Prismatic Powders", "PP-BGN-001", 90, 30, 80, 5.00m),
QuantityOnHand = 150, Pwd("PWD-PLM-001", "Plum", "Plum", "RAL 4007", "Matte", "Prismatic Powders", "PP-PLM-001", 65, 25, 70, 5.25m),
UnitOfMeasure = "lbs", Pwd("PWD-CNO-001", "Candy Orange", "Candy Orange", "RAL 2004", "Candy", "Prismatic Powders", "PP-CNO-001", 50, 20, 60, 7.50m),
ReorderPoint = 50, Pwd("PWD-CNG-001", "Candy Green", "Candy Green", "RAL 6002", "Candy", "Prismatic Powders", "PP-CNG-001", 55, 20, 60, 7.50m),
ReorderQuantity = 100, Pwd("PWD-CNP-001", "Candy Purple", "Candy Purple", "RAL 4005", "Candy", "Prismatic Powders", "PP-CNP-001", 55, 20, 60, 7.50m),
MinimumStock = 25,
MaximumStock = 500, // ── Columbia Coatings ─────────────────────────────────────────────────
UnitCost = 5.75m, Pwd("CC-LIV-001", "Light Ivory", "Light Ivory", "RAL 1015", "Gloss", "Columbia Coatings", "CC-1015-GLO", 120, 40, 100, 4.50m),
AverageCost = 5.75m, Pwd("CC-TYL-001", "Traffic Yellow", "Traffic Yellow", "RAL 1023", "Gloss", "Columbia Coatings", "CC-1023-GLO", 100, 35, 90, 4.75m),
LastPurchasePrice = 5.75m, Pwd("CC-POR-001", "Pure Orange", "Pure Orange", "RAL 2004", "Gloss", "Columbia Coatings", "CC-2004-GLO", 80, 30, 80, 4.75m),
LastPurchaseDate = DateTime.UtcNow.AddDays(-20), Pwd("CC-RBR-001", "Ruby Red", "Ruby Red", "RAL 3003", "Gloss", "Columbia Coatings", "CC-3003-GLO", 100, 35, 90, 5.00m),
CoverageSqFtPerLb = 30m, Pwd("CC-WNR-001", "Wine Red Matte", "Wine Red", "RAL 3005", "Matte", "Columbia Coatings", "CC-3005-MAT", 80, 30, 80, 5.00m),
TransferEfficiency = 65m, Pwd("CC-RVL-001", "Red Violet", "Red Violet", "RAL 4002", "Satin", "Columbia Coatings", "CC-4002-SAT", 60, 25, 70, 5.25m),
IsActive = true, Pwd("CC-SAP-001", "Sapphire Blue", "Sapphire Blue", "RAL 5003", "Gloss", "Columbia Coatings", "CC-5003-GLO", 100, 35, 90, 5.00m),
CompanyId = company.Id, Pwd("CC-SKB-001", "Sky Blue", "Sky Blue", "RAL 5015", "Gloss", "Columbia Coatings", "CC-5015-GLO", 90, 30, 80, 4.75m),
CreatedAt = DateTime.UtcNow Pwd("CC-LFG-001", "Leaf Green", "Leaf Green", "RAL 6002", "Gloss", "Columbia Coatings", "CC-6002-GLO", 100, 35, 90, 4.75m),
}, Pwd("CC-MSG-001", "Moss Green", "Moss Green", "RAL 6005", "Matte", "Columbia Coatings", "CC-6005-MAT", 80, 30, 80, 5.00m),
new InventoryItem Pwd("CC-SVG-001", "Silver Gray", "Silver Gray", "RAL 7001", "Satin", "Columbia Coatings", "CC-7001-SAT", 120, 40, 100, 4.75m),
{ Pwd("CC-ANT-001", "Anthracite", "Anthracite", "RAL 7016", "Matte", "Columbia Coatings", "CC-7016-MAT", 150, 50, 120, 4.75m),
SKU = $"{skuPrefix}-PWD-BLU-001", Pwd("CC-LGY-001", "Light Gray", "Light Gray", "RAL 7035", "Gloss", "Columbia Coatings", "CC-7035-GLO", 180, 60, 150, 4.50m),
Name = "Metallic Blue Powder", Pwd("CC-TGY-001", "Traffic Gray", "Traffic Gray", "RAL 7042", "Satin", "Columbia Coatings", "CC-7042-SAT", 120, 40, 100, 4.75m),
Description = "Metallic blue powder coating with shimmer", Pwd("CC-OCH-001", "Ochre Brown", "Ochre Brown", "RAL 8001", "Gloss", "Columbia Coatings", "CC-8001-GLO", 80, 30, 80, 4.75m),
Category = "Powder", Pwd("CC-GBR-001", "Grey Brown", "Grey Brown", "RAL 8019", "Matte", "Columbia Coatings", "CC-8019-MAT", 70, 25, 70, 5.00m),
InventoryCategoryId = powderCategory?.Id, Pwd("CC-CRM-001", "Cream", "Cream", "RAL 9001", "Gloss", "Columbia Coatings", "CC-9001-GLO", 150, 50, 120, 4.50m),
ColorName = "Metallic Blue", Pwd("CC-PWH-001", "Pure White", "Pure White", "RAL 9010", "Gloss", "Columbia Coatings", "CC-9010-GLO", 200, 60, 150, 4.50m),
ColorCode = "RAL 5002", Pwd("CC-TWH-001", "Traffic White", "Traffic White", "RAL 9016", "Gloss", "Columbia Coatings", "CC-9016-GLO", 180, 60, 150, 4.50m),
Finish = "Metallic", Pwd("CC-CLG-001", "Clear Gloss", "Clear", "N/A", "Gloss", "Columbia Coatings", "CC-CLR-GLO", 100, 35, 90, 6.75m),
Manufacturer = "Axalta",
ManufacturerPartNumber = "AX-MB-001", // ── Tiger Drylac ──────────────────────────────────────────────────────
QuantityOnHand = 200, Pwd("TD-HTK-001", "High-Temp Black 1000F", "High-Temp Black", "N/A", "High-Temp", "Tiger Drylac", "TD-49-90000", 60, 25, 60, 14.50m),
UnitOfMeasure = "lbs", Pwd("TD-HTA-001", "High-Temp Aluminum 1000F", "High-Temp Aluminum", "RAL 9006", "High-Temp", "Tiger Drylac", "TD-49-90001", 50, 20, 50, 14.50m),
ReorderPoint = 75, Pwd("TD-EPG-001", "Epoxy Primer Gray", "Epoxy Primer Gray", "RAL 7001", "Primer", "Tiger Drylac", "TD-68-00000", 100, 35, 90, 8.75m),
ReorderQuantity = 150, Pwd("TD-EPR-001", "Epoxy Primer Red Oxide", "Epoxy Primer Red", "RAL 3009", "Primer", "Tiger Drylac", "TD-68-30000", 80, 30, 80, 8.75m),
MinimumStock = 25, Pwd("TD-CLG-001", "Clear Gloss Topcoat", "Clear Gloss", "N/A", "Gloss", "Tiger Drylac", "TD-00-00000", 75, 25, 70, 9.50m),
MaximumStock = 500, Pwd("TD-CLM-001", "Clear Matte Topcoat", "Clear Matte", "N/A", "Matte", "Tiger Drylac", "TD-00-00001", 60, 20, 60, 9.50m),
UnitCost = 6.25m, Pwd("TD-AFG-001", "Anti-Graffiti Clear", "Anti-Graffiti", "N/A", "Clear", "Tiger Drylac", "TD-AG-00000", 40, 15, 50, 15.75m),
AverageCost = 6.25m, Pwd("TD-DKB-001", "Dark Bronze", "Dark Bronze", "RAL 8019", "Satin", "Tiger Drylac", "TD-15-50000", 80, 30, 80, 7.25m),
LastPurchasePrice = 6.25m, Pwd("TD-NAL-001", "Natural Aluminum", "Natural Aluminum", "RAL 9006", "Satin", "Tiger Drylac", "TD-15-60000", 70, 25, 70, 7.00m),
LastPurchaseDate = DateTime.UtcNow.AddDays(-15), Pwd("TD-MTG-001", "Machine Tool Green", "Machine Tool Green", "RAL 6011", "Gloss", "Tiger Drylac", "TD-57-60000", 50, 20, 60, 5.50m),
CoverageSqFtPerLb = 30m, Pwd("TD-MCG-001", "Machinery Gray", "Machinery Gray", "RAL 7040", "Gloss", "Tiger Drylac", "TD-57-00000", 60, 25, 70, 5.50m),
TransferEfficiency = 65m, Pwd("TD-UBZ-001", "Urban Bronze", "Urban Bronze", "RAL 8024", "Satin", "Tiger Drylac", "TD-15-80000", 75, 25, 70, 7.50m),
IsActive = true, Pwd("TD-NYL-001", "Nylon Black Functional", "Nylon Black", "RAL 9005", "Functional", "Tiger Drylac", "TD-NY-00000", 40, 15, 50, 18.50m),
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow // ── Sherwin-Williams Powder Coatings ──────────────────────────────────
}, Pwd("SW-STK-001", "Satin Black", "Satin Black", "RAL 9005", "Satin", "Sherwin-Williams Powders", "SW-STK-001", 100, 35, 90, 5.25m),
new InventoryItem Pwd("SW-STG-001", "Satin Gray", "Satin Gray", "RAL 7035", "Satin", "Sherwin-Williams Powders", "SW-STG-001", 80, 30, 80, 5.25m),
{ Pwd("SW-STB-001", "Satin Bronze", "Satin Bronze", "RAL 8019", "Satin", "Sherwin-Williams Powders", "SW-STB-001", 70, 25, 70, 5.75m),
SKU = $"{skuPrefix}-PWD-GRY-001", Pwd("SW-WST-001", "White Satin", "White Satin", "RAL 9003", "Satin", "Sherwin-Williams Powders", "SW-WST-001", 100, 35, 90, 5.00m),
Name = "Textured Gray Powder", Pwd("SW-UBZ-001", "Urban Bronze", "Urban Bronze", "RAL 8024", "Satin", "Sherwin-Williams Powders", "SW-UBZ-001", 65, 25, 70, 6.00m),
Description = "Textured gray powder coating for industrial use", Pwd("SW-MBR-001", "Mission Brown", "Mission Brown", "RAL 8025", "Matte", "Sherwin-Williams Powders", "SW-MBR-001", 60, 25, 70, 5.50m),
Category = "Powder", Pwd("SW-PGR-001", "Patina Green", "Patina Green", "RAL 6011", "Matte", "Sherwin-Williams Powders", "SW-PGR-001", 50, 20, 60, 5.75m),
InventoryCategoryId = powderCategory?.Id, Pwd("SW-AGC-001", "Aged Copper", "Aged Copper", "RAL 8023", "Metallic", "Sherwin-Williams Powders", "SW-AGC-001", 55, 20, 60, 8.25m),
ColorName = "Textured Gray", Pwd("SW-DGY-001", "Dark Gray Satin", "Dark Gray", "RAL 7016", "Satin", "Sherwin-Williams Powders", "SW-DGY-001", 80, 30, 80, 5.25m),
ColorCode = "RAL 7037",
Finish = "Textured", // ── Masking Supplies ──────────────────────────────────────────────────
Manufacturer = "Axalta", Supply("MSK-HT12-001", "Masking Tape 1/2 inch", "High-temp masking tape 1/2-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 60, 20, 60, 4.25m),
ManufacturerPartNumber = "AX-TG-001", Supply("MSK-HT1-001", "Masking Tape 1 inch", "High-temp masking tape 1-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 80, 25, 75, 6.50m),
QuantityOnHand = 300, Supply("MSK-HT3-001", "Masking Tape 3 inch", "High-temp masking tape 3-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 40, 15, 50, 9.50m),
UnitOfMeasure = "lbs", Supply("MSK-HT4-001", "Masking Tape 4 inch", "High-temp masking tape 4-inch wide, 60-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 30, 10, 40, 11.75m),
ReorderPoint = 75, Supply("MSK-PAP12-001", "Masking Paper 12 inch", "High-temp masking paper roll, 12-inch x 60 yards", "Masking Supplies", maskingCat?.Id, "rolls", 25, 8, 25, 14.50m),
ReorderQuantity = 150, Supply("MSK-PAP18-001", "Masking Paper 18 inch", "High-temp masking paper roll, 18-inch x 60 yards", "Masking Supplies", maskingCat?.Id, "rolls", 20, 8, 25, 19.75m),
MinimumStock = 50, Supply("MSK-PAP24-001", "Masking Paper 24 inch", "High-temp masking paper roll, 24-inch x 60 yards", "Masking Supplies", maskingCat?.Id, "rolls", 15, 5, 20, 24.50m),
MaximumStock = 600, Supply("MSK-PLG14-001", "Silicone Plugs 1/4 in", "High-temp silicone plugs 1/4-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 30, 10, 30, 11.25m),
UnitCost = 5.00m, Supply("MSK-PLG38-001", "Silicone Plugs 3/8 in", "High-temp silicone plugs 3/8-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 25, 10, 25, 12.50m),
AverageCost = 5.00m, Supply("MSK-PLG12-001", "Silicone Plugs 1/2 in", "High-temp silicone plugs 1/2-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 20, 8, 25, 13.75m),
LastPurchasePrice = 5.00m, Supply("MSK-PLG58-001", "Silicone Plugs 5/8 in", "High-temp silicone plugs 5/8-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 18, 6, 20, 15.00m),
LastPurchaseDate = DateTime.UtcNow.AddDays(-10), Supply("MSK-PLG34-001", "Silicone Plugs 3/4 in", "High-temp silicone plugs 3/4-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 15, 5, 20, 16.50m),
CoverageSqFtPerLb = 30m, Supply("MSK-PLG1-001", "Silicone Plugs 1 inch", "High-temp silicone plugs 1-inch (bag of 100)", "Masking Supplies", maskingCat?.Id, "bags", 12, 5, 15, 18.75m),
TransferEfficiency = 65m, Supply("MSK-CAP1-001", "Masking Caps 1 inch", "High-temp square masking caps 1-inch (bag of 50)", "Masking Supplies", maskingCat?.Id, "bags", 25, 8, 25, 9.50m),
IsActive = true, Supply("MSK-CAP2-001", "Masking Caps 2 inch", "High-temp square masking caps 2-inch (bag of 50)", "Masking Supplies", maskingCat?.Id, "bags", 20, 6, 20, 12.25m),
CompanyId = company.Id, Supply("MSK-DSC2-001", "Masking Discs 2 inch", "Round masking discs 2-inch (pack of 100)", "Masking Supplies", maskingCat?.Id, "packs", 20, 6, 20, 8.75m),
CreatedAt = DateTime.UtcNow Supply("MSK-DSC3-001", "Masking Discs 3 inch", "Round masking discs 3-inch (pack of 100)", "Masking Supplies", maskingCat?.Id, "packs", 15, 5, 15, 9.25m),
}, Supply("MSK-VNL-001", "High-Temp Vinyl Tape", "High-temp vinyl tape 1-inch, 36-yd roll", "Masking Supplies", maskingCat?.Id, "rolls", 40, 12, 40, 7.75m),
new InventoryItem Supply("MSK-FOM-001", "Foam Plugs Assorted", "High-temp foam masking plugs assorted sizes (bag of 50)","Masking Supplies", maskingCat?.Id, "bags", 20, 6, 20, 10.50m),
{
SKU = $"{skuPrefix}-PWD-YEL-001", // ── Chemical Pretreatments & Cleaners ────────────────────────────────
Name = "Safety Yellow Powder", Supply("CHM-IPH-001", "Iron Phosphate Pretreatment", "Iron phosphate pretreatment concentrate, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 10, 3, 10, 42.50m),
Description = "High-visibility safety yellow powder coating", Supply("CHM-ZPH-001", "Zinc Phosphate Pretreatment", "Zinc phosphate pretreatment concentrate, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 8, 2, 8, 58.75m),
Category = "Powder", Supply("CHM-CFP-001", "Chrome-Free Pretreatment", "Chrome-free conversion coating pretreatment, 5-gal", "Cleaner", cleanerCat?.Id, "gallons", 5, 2, 6, 67.50m),
InventoryCategoryId = powderCategory?.Id, Supply("CHM-ALD-001", "Alkaline Degreaser", "Heavy-duty alkaline degreaser concentrate, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 15, 5, 15, 38.50m),
ColorName = "Safety Yellow", Supply("CHM-CTD-001", "Citrus Degreaser", "Citrus-based degreaser concentrate, 1-gallon", "Cleaner", cleanerCat?.Id, "gallons", 12, 4, 12, 24.75m),
ColorCode = "RAL 1003", Supply("CHM-RIN-001", "Rust Inhibitor", "Water-based rust inhibitor for pretreated metal, 1-gal","Cleaner",cleanerCat?.Id,"gallons", 8, 3, 8, 31.25m),
Finish = "Gloss", Supply("CHM-MET-001", "Metal Etch", "Acid etch for aluminum and non-ferrous metals, 1-gal", "Cleaner", cleanerCat?.Id, "gallons", 10, 3, 10, 27.50m),
Manufacturer = "Tiger Drylac", Supply("CHM-CCT-001", "Conversion Coating", "Self-etching zinc conversion coating, 5-gallon", "Cleaner", cleanerCat?.Id, "gallons", 6, 2, 6, 78.00m),
ManufacturerPartNumber = "TG-SY-001", Supply("CHM-OGS-001", "Outgassing Agent", "Prevents outgassing pinholes on porous castings, 1-gal","Cleaner",cleanerCat?.Id,"gallons", 4, 2, 5, 44.50m),
QuantityOnHand = 125, Supply("CHM-FRP-001", "Flash Rust Preventer", "Prevents flash rust between pretreat and coat, 1-gal", "Cleaner", cleanerCat?.Id, "gallons", 6, 2, 6, 29.75m),
UnitOfMeasure = "lbs", Supply("CHM-EQP-001", "Equipment Cleaner", "Gun and equipment cleaner/solvent, 1-gallon", "Cleaner", cleanerCat?.Id, "gallons", 8, 3, 8, 22.50m),
ReorderPoint = 50, Supply("CHM-PHN-001", "pH Neutralizer", "Neutralizes pretreatment rinse water, 1-gallon", "Cleaner", cleanerCat?.Id, "gallons", 6, 2, 6, 18.75m),
ReorderQuantity = 100, Supply("CHM-ZNP-001", "Zinc Phosphate Powder", "Dry zinc phosphate powder, 10-lb bag", "Cleaner", cleanerCat?.Id, "bags", 5, 2, 5, 35.00m),
MinimumStock = 25, Supply("CHM-IPC-001", "Iron Phosphate Powder", "Dry iron phosphate powder, 10-lb bag", "Cleaner", cleanerCat?.Id, "bags", 5, 2, 5, 28.00m),
MaximumStock = 400, Supply("CHM-APR-001", "Adhesion Promoter", "Surface adhesion promoter for difficult substrates, qt","Cleaner",cleanerCat?.Id,"quarts", 10, 3, 10, 19.50m),
UnitCost = 5.50m,
AverageCost = 5.50m, // ── Abrasive Media ────────────────────────────────────────────────────
LastPurchasePrice = 5.50m, Supply("ABR-AO80-001", "Aluminum Oxide 80 Grit", "80-grit aluminum oxide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 12, 4, 12, 28.50m),
LastPurchaseDate = DateTime.UtcNow.AddDays(-5), Supply("ABR-AO150-001", "Aluminum Oxide 150 Grit", "150-grit aluminum oxide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 29.50m),
CoverageSqFtPerLb = 30m, Supply("ABR-AO180-001", "Aluminum Oxide 180 Grit", "180-grit aluminum oxide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 6, 2, 6, 30.50m),
TransferEfficiency = 65m, Supply("ABR-SS70-001", "Steel Shot S-70", "Steel shot S-70, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 32.75m),
IsActive = true, Supply("ABR-SS110-001", "Steel Shot S-110", "Steel shot S-110, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 33.50m),
CompanyId = company.Id, Supply("ABR-SS230-001", "Steel Shot S-230", "Steel shot S-230, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 34.75m),
CreatedAt = DateTime.UtcNow Supply("ABR-SG25-001", "Steel Grit G-25", "Steel grit G-25, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 35.50m),
}, Supply("ABR-SG40-001", "Steel Grit G-40", "Steel grit G-40, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 36.25m),
new InventoryItem Supply("ABR-GB8-001", "Glass Bead No.8", "Glass bead No. 8, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 27.50m),
{ Supply("ABR-GB13-001", "Glass Bead No.13", "Glass bead No. 13, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 8, 3, 8, 28.75m),
SKU = $"{skuPrefix}-PWD-ORG-001", Supply("ABR-GRN-001", "Garnet 80 Grit", "80-grit garnet blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 10, 3, 10, 38.50m),
Name = "Orange Powder", Supply("ABR-SIC-001", "Silicon Carbide 80 Grit", "80-grit silicon carbide blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 6, 2, 6, 62.75m),
Description = "Bright orange powder coating", Supply("ABR-PLM-001", "Plastic Media Type I", "Plastic abrasive media Type I, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 5, 2, 5, 45.00m),
Category = "Powder", Supply("ABR-WLN-001", "Walnut Shell Medium", "Walnut shell medium-grit blasting media, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 5, 2, 5, 32.50m),
InventoryCategoryId = powderCategory?.Id, Supply("ABR-CRN-001", "Corn Cob Media", "Corn cob blasting media medium-grit, 50-lb bag", "Abrasive Media", abrasiveCat?.Id, "bags", 5, 2, 5, 27.00m),
ColorName = "Pure Orange",
ColorCode = "RAL 2004", // ── Hanging Hardware ──────────────────────────────────────────────────
Finish = "Gloss", Supply("HDW-JHS-001", "J-Hooks Small", "1/8-inch wire J-hooks for small parts (box of 100)", "Consumables", consumeCat?.Id, "boxes", 15, 5, 15, 9.75m),
Manufacturer = "Axalta", Supply("HDW-JHM-001", "J-Hooks Medium", "3/16-inch wire J-hooks for medium parts (box of 100)", "Consumables", consumeCat?.Id, "boxes", 12, 4, 12, 13.50m),
ManufacturerPartNumber = "AX-PO-001", Supply("HDW-JHL-001", "J-Hooks Large", "1/4-inch wire J-hooks for heavy parts (box of 50)", "Consumables", consumeCat?.Id, "boxes", 10, 3, 10, 17.25m),
QuantityOnHand = 100, Supply("HDW-SHS-001", "S-Hooks Small", "Small S-hooks for racking light parts (box of 100)", "Consumables", consumeCat?.Id, "boxes", 12, 4, 12, 8.50m),
UnitOfMeasure = "lbs", Supply("HDW-SHL-001", "S-Hooks Large", "Large S-hooks for heavy racking (box of 50)", "Consumables", consumeCat?.Id, "boxes", 8, 3, 8, 14.75m),
ReorderPoint = 40, Supply("HDW-GRW-001", "Ground Wire 10ft Coil", "Grounding wire coil for proper gun grounding (each)", "Consumables", consumeCat?.Id, "each", 20, 5, 15, 4.50m),
ReorderQuantity = 80, Supply("HDW-RB24-001", "Racking Bar 24 inch", "24-inch steel racking cross bar (each)", "Consumables", consumeCat?.Id, "each", 15, 5, 10, 12.75m),
MinimumStock = 20, Supply("HDW-RB36-001", "Racking Bar 36 inch", "36-inch steel racking cross bar (each)", "Consumables", consumeCat?.Id, "each", 10, 3, 10, 17.50m),
MaximumStock = 300, Supply("HDW-W14-001", "Hang Wire 14-Gauge", "14-gauge steel hang wire, 100-ft roll", "Consumables", consumeCat?.Id, "rolls", 10, 3, 10, 8.25m),
UnitCost = 5.85m, Supply("HDW-W12-001", "Hang Wire 12-Gauge", "12-gauge steel hang wire, 100-ft roll", "Consumables", consumeCat?.Id, "rolls", 10, 3, 10, 11.50m),
AverageCost = 5.85m, Supply("HDW-QCC-001", "Quick-Clamp Connectors","Powder coating quick-clamp connectors (bag of 25)", "Consumables", consumeCat?.Id, "bags", 15, 5, 15, 14.75m),
LastPurchasePrice = 5.85m, Supply("HDW-TBR-001", "T-Bar Fixture", "T-bar part fixture for flat panel racking (each)", "Consumables", consumeCat?.Id, "each", 8, 2, 5, 22.50m),
LastPurchaseDate = DateTime.UtcNow.AddDays(-12), Supply("HDW-WCA-001", "Wheel Cone Adapters", "Wheel cone adapters for rim coating (set of 4)", "Consumables", consumeCat?.Id, "sets", 6, 2, 4, 34.75m),
CoverageSqFtPerLb = 30m, Supply("HDW-THR-001", "Threaded Rod Hangers", "6-inch threaded rod hangers for custom racking (box of 10)", "Consumables", consumeCat?.Id, "boxes", 8, 2, 6, 19.25m),
TransferEfficiency = 65m,
IsActive = true, // ── PPE & Safety ──────────────────────────────────────────────────────
CompanyId = company.Id, Supply("PPE-GVM-001", "Nitrile Gloves Medium", "Nitrile exam gloves medium, powder-free (box of 100)", "Consumables", consumeCat?.Id, "boxes", 10, 3, 10, 12.75m),
CreatedAt = DateTime.UtcNow Supply("PPE-GVL-001", "Nitrile Gloves Large", "Nitrile exam gloves large, powder-free (box of 100)", "Consumables", consumeCat?.Id, "boxes", 10, 3, 10, 12.75m),
}, Supply("PPE-RES-001", "Half-Face Respirator", "Half-face respirator with P100/OV cartridges (each)", "Consumables", consumeCat?.Id, "each", 6, 2, 4, 38.50m),
new InventoryItem Supply("PPE-FLT-001", "P100 Filter Cartridges", "P100 organic vapor replacement cartridges (pair)", "Consumables", consumeCat?.Id, "pairs", 12, 4, 12, 17.25m),
{ Supply("PPE-SGL-001", "Safety Glasses", "ANSI Z87.1 safety glasses, clear lens (each)", "Consumables", consumeCat?.Id, "each", 20, 6, 12, 4.50m),
SKU = $"{skuPrefix}-PWD-GRN-001", Supply("PPE-FSH-001", "Face Shield", "Adjustable face shield, 8-inch (each)", "Consumables", consumeCat?.Id, "each", 6, 2, 4, 18.75m),
Name = "Forest Green Powder", Supply("PPE-CVR-001", "Tyvek Coverall", "Tyvek disposable coverall, medium (each)", "Consumables", consumeCat?.Id, "each", 15, 5, 15, 11.50m),
Description = "Deep forest green powder coating",
Category = "Powder",
InventoryCategoryId = powderCategory?.Id,
ColorName = "Forest Green",
ColorCode = "RAL 6009",
Finish = "Matte",
Manufacturer = "Tiger Drylac",
ManufacturerPartNumber = "TG-FG-001",
QuantityOnHand = 175,
UnitOfMeasure = "lbs",
ReorderPoint = 60,
ReorderQuantity = 120,
MinimumStock = 30,
MaximumStock = 400,
UnitCost = 5.25m,
AverageCost = 5.25m,
LastPurchasePrice = 5.25m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-8),
CoverageSqFtPerLb = 30m,
TransferEfficiency = 65m,
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-CLN-001",
Name = "Pre-Treatment Cleaner",
Description = "Industrial degreaser and cleaner",
Category = "Cleaner",
InventoryCategoryId = cleanerCategory?.Id,
QuantityOnHand = 50,
UnitOfMeasure = "gallons",
ReorderPoint = 10,
ReorderQuantity = 25,
MinimumStock = 5,
MaximumStock = 100,
UnitCost = 12.50m,
AverageCost = 12.50m,
LastPurchasePrice = 12.50m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-20),
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
},
new InventoryItem
{
SKU = $"{skuPrefix}-MSK-001",
Name = "High-Temp Masking Tape",
Description = "Heat-resistant masking tape for powder coating",
Category = "Masking",
InventoryCategoryId = maskingCategory?.Id,
QuantityOnHand = 200,
UnitOfMeasure = "rolls",
ReorderPoint = 50,
ReorderQuantity = 100,
MinimumStock = 25,
MaximumStock = 500,
UnitCost = 8.75m,
AverageCost = 8.75m,
LastPurchasePrice = 8.75m,
LastPurchaseDate = DateTime.UtcNow.AddDays(-15),
IsActive = true,
CompanyId = company.Id,
CreatedAt = DateTime.UtcNow
}
}; };
// Add inventory items one at a time to handle duplicates gracefully // Add inventory items one at a time to handle duplicates gracefully
@@ -1242,11 +1266,54 @@ public partial class SeedDataService : ISeedDataService
var vendors = new List<Vendor> var vendors = new List<Vendor>
{ {
new Vendor { CompanyId = company.Id, CompanyName = "Prismatic Powders", ContactName = "Sales", Email = "sales@prismaticpowders.com", Phone = "800-867-4445", Website = "https://www.prismaticpowders.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, // ── Powder Suppliers ─────────────────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Columbia Coatings", ContactName = "Sales", Email = "info@columbiacoatings.com", Phone = "888-265-8247", Website = "https://www.columbiacoatings.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Prismatic Powders", ContactName = "Sales", Email = "sales@prismaticpowders.com", Phone = "800-867-4445", Website = "https://www.prismaticpowders.com", PaymentTerms = "Net 30", IsActive = true, IsPreferred = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Sherwin-Williams Industrial", ContactName = "Account Rep", Email = "industrial@sherwin-williams.com", Phone = "800-524-5979", Website = "https://www.sherwin-williams.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Columbia Coatings", ContactName = "Sales", Email = "info@columbiacoatings.com", Phone = "888-265-8247", Website = "https://www.columbiacoatings.com", PaymentTerms = "Net 30", IsActive = true, IsPreferred = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Ace Hardware Supply", ContactName = "Purchasing", Email = "supply@acehardware.com", Phone = "630-990-6600", Website = "https://www.acehardware.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Tiger Drylac USA", ContactName = "Sales", Email = "sales@tigerdrylac.com", Phone = "888-487-9090", Website = "https://www.tigerdrylac.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Fastenal Industrial", ContactName = "Sales Team", Email = "sales@fastenal.com", Phone = "507-454-5374", Website = "https://www.fastenal.com", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow }, new Vendor { CompanyId = company.Id, CompanyName = "Sherwin-Williams Powders", ContactName = "Sales Rep", Email = "powders@sherwin-williams.com", Phone = "800-321-8194", Website = "https://www.sherwin-williams.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Eastwood Company", ContactName = "Customer Svc", Email = "support@eastwood.com", Phone = "800-343-9353", Website = "https://www.eastwood.com", PaymentTerms = "Net 15", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
// ── Industrial & Hardware Suppliers ──────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Grainger Industrial Supply", ContactName = "Account Rep", Email = "accounts@grainger.com", Phone = "800-472-4643", Website = "https://www.grainger.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "MSC Industrial Supply", ContactName = "Account Rep", Email = "accounts@mscdirect.com", Phone = "800-645-7270", Website = "https://www.mscdirect.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "McMaster-Carr", ContactName = "Customer Svc", Email = "orders@mcmaster.com", Phone = "630-833-0300", Website = "https://www.mcmaster.com", PaymentTerms = "Credit Card", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Fastenal", ContactName = "Branch Mgr", Email = "nc.raleigh@fastenal.com", Phone = "(919) 833-2120", Website = "https://www.fastenal.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Harbor Freight Tools", ContactName = "Purchasing", Email = "purchasing@harborfreight.com", Phone = "800-444-3353", Website = "https://www.harborfreight.com", PaymentTerms = "Credit Card", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Uline", ContactName = "Sales", Email = "customer.service@uline.com", Phone = "800-295-5510", Website = "https://www.uline.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Local Industrial Supply", ContactName = "Sales Team", Email = "sales@localindustrialsupply.com", Phone = "(919) 555-0100", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Amazon Business", ContactName = "Account Mgr", Email = "business@amazon.com", Phone = "888-281-3847", Website = "https://business.amazon.com", PaymentTerms = "Credit Card", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Lowe's Pro Supply", ContactName = "Pro Desk", Email = "pro@lowes.com", Phone = "(919) 555-0920", Website = "https://www.lowes.com/l/pro.html", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "NAPA Auto Parts", ContactName = "Store Mgr", Email = "raleigh.south@napa.com", Phone = "(919) 555-0502", Website = "https://www.napaonline.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
// ── Abrasive & Blasting Suppliers ─────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Clemco Industries", ContactName = "Sales", Email = "sales@clemcoindustries.com", Phone = "314-770-0377", Website = "https://www.clemcoindustries.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Triangle Abrasives Co", ContactName = "John Marks", Email = "jmarks@triangleabrasives.com", Phone = "(919) 555-0305", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
// ── Industrial Gases ──────────────────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Airgas USA", ContactName = "Account Mgr", Email = "nc.accounts@airgas.com", Phone = "(919) 555-0210", Website = "https://www.airgas.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Linde Gas & Equipment", ContactName = "Route Mgr", Email = "nc.service@linde.com", Phone = "800-755-9277", Website = "https://www.lindeus.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
// ── Utilities & Services ──────────────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Duke Energy Business", ContactName = "Account Svc", Email = "business@duke-energy.com", Phone = "800-777-9898", Website = "https://www.duke-energy.com", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "AT&T Business Solutions", ContactName = "Account Rep", Email = "smb@att.com", Phone = "800-321-2000", Website = "https://www.att.com/smallbusiness", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Spectrum Business", ContactName = "Account Mgr", Email = "business@spectrum.com", Phone = "855-707-7328", Website = "https://business.spectrum.com", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Raleigh Electric Supply", ContactName = "Counter Sales", Email = "sales@raleighelectric.com", Phone = "(919) 555-0815", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Carolina Industrial Water", ContactName = "Service Tech", Email = "service@carolinawater.com", Phone = "(919) 555-1102", Notes = "Water filtration and treatment service for spray booth wash system.", PaymentTerms = "Quarterly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
// ── Waste & Environmental ─────────────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Safety-Kleen Systems", ContactName = "Route Driver", Email = "nc.service@safety-kleen.com", Phone = "800-669-5740", Website = "https://www.safety-kleen.com", PaymentTerms = "Monthly", IsActive = true, Country = "USA", Notes = "Solvent recycling and chemical waste disposal.", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Raleigh Waste Solutions", ContactName = "Route Mgr", Email = "service@raleighwaste.com", Phone = "(919) 555-0604", PaymentTerms = "Monthly", IsActive = true, Country = "USA", Notes = "Dumpster and roll-off container service.", CreatedAt = DateTime.UtcNow },
// ── Safety & PPE ──────────────────────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Work N Gear Safety", ContactName = "Sales", Email = "sales@workngear.com", Phone = "800-967-9327", Website = "https://www.workngear.com", PaymentTerms = "Net 15", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
// ── Office & Business Services ────────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "HD Supply", ContactName = "Account Rep", Email = "accounts@hdsupply.com", Phone = "800-431-3000", Website = "https://www.hdsupply.com", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "Carolina Office Products", ContactName = "Sales Rep", Email = "sales@carolinaoffice.com", Phone = "(919) 555-0712", PaymentTerms = "Net 30", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
new Vendor { CompanyId = company.Id, CompanyName = "First Insurance Solutions", ContactName = "Agent", Email = "agents@firstinsurance.com", Phone = "(919) 555-1001", Notes = "Business liability and equipment insurance policy.", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
// ── Facility (Landlord) ───────────────────────────────────────────────
new Vendor { CompanyId = company.Id, CompanyName = "Triangle Commercial Properties LLC", ContactName = "Property Mgr", Email = "rentals@trianglecommercial.com", Phone = "(919) 555-0401", Notes = "Shop lease — 4,800 sq ft at 4712 Industrial Blvd, Raleigh NC 27616. Rent due 1st of each month.", PaymentTerms = "Monthly", IsActive = true, Country = "USA", CreatedAt = DateTime.UtcNow },
}; };
await _context.Set<Vendor>().AddRangeAsync(vendors); await _context.Set<Vendor>().AddRangeAsync(vendors);
@@ -126,8 +126,9 @@ public class AccountDataExportController : Controller
{ {
switch (sheet) switch (sheet)
{ {
case "Customers": await AddCustomersSheet(package, companyId, headerColor); break; case "Customers": await AddCustomersSheet(package, companyId, headerColor); break;
case "Jobs": await AddJobsSheet(package, companyId, headerColor); break; case "CustomerContacts": await AddCustomerContactsSheet(package, companyId, headerColor); break;
case "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break; case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break; case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break; case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
@@ -174,8 +175,9 @@ public class AccountDataExportController : Controller
{ {
switch (sheet) switch (sheet)
{ {
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break; case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break;
case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break; case "CustomerContacts": WriteCsvEntry(zip, "CustomerContacts.csv", await BuildCustomerContactsCsv(companyId)); break;
case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break; case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break; case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break; case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
@@ -299,7 +301,9 @@ public class AccountDataExportController : Controller
var data = await FetchCustomersAsync(companyId); var data = await FetchCustomersAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Customers"); var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone", var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
"Commercial", "City", "State", "Active", "Credit Limit", "Current Balance", "Created At" }; "Commercial", "City", "State", "Active", "Credit Limit", "Current Balance",
"Lead Source", "Ship-To Address", "Ship-To City", "Ship-To State", "Ship-To Zip", "Ship-To Country",
"Created At" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
{ {
@@ -311,7 +315,34 @@ public class AccountDataExportController : Controller
ws.Cells[r, 8].Value = c.City; ws.Cells[r, 9].Value = c.State; ws.Cells[r, 8].Value = c.City; ws.Cells[r, 9].Value = c.State;
ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No"; ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No";
ws.Cells[r, 11].Value = c.CreditLimit; ws.Cells[r, 12].Value = c.CurrentBalance; ws.Cells[r, 11].Value = c.CreditLimit; ws.Cells[r, 12].Value = c.CurrentBalance;
ws.Cells[r, 13].Value = c.CreatedAt.ToString("yyyy-MM-dd"); ws.Cells[r, 13].Value = c.LeadSource;
ws.Cells[r, 14].Value = c.ShipToAddress; ws.Cells[r, 15].Value = c.ShipToCity;
ws.Cells[r, 16].Value = c.ShipToState; ws.Cells[r, 17].Value = c.ShipToZipCode;
ws.Cells[r, 18].Value = c.ShipToCountry;
ws.Cells[r, 19].Value = c.CreatedAt.ToString("yyyy-MM-dd");
}
AutoFit(ws, headers.Length);
}
private async Task AddCustomerContactsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.CustomerContacts.AsNoTracking()
.Include(cc => cc.Customer)
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("CustomerContacts");
var headers = new[] { "CustomerEmail", "FirstName", "LastName", "Title", "ContactRole", "Email", "Phone", "MobilePhone", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2; var cc = data[i];
ws.Cells[r, 1].Value = cc.Customer?.Email;
ws.Cells[r, 2].Value = cc.FirstName; ws.Cells[r, 3].Value = cc.LastName;
ws.Cells[r, 4].Value = cc.Title; ws.Cells[r, 5].Value = cc.ContactRole;
ws.Cells[r, 6].Value = cc.Email; ws.Cells[r, 7].Value = cc.Phone;
ws.Cells[r, 8].Value = cc.MobilePhone; ws.Cells[r, 9].Value = cc.Notes;
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -326,7 +357,7 @@ public class AccountDataExportController : Controller
var data = await FetchJobsAsync(companyId); var data = await FetchJobsAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Jobs"); var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority", var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
"Description", "Due Date", "Final Price", "Created At" }; "Description", "Project Name", "Due Date", "Final Price", "Created At" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
{ {
@@ -337,9 +368,10 @@ public class AccountDataExportController : Controller
ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString(); ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString();
ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString(); ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
ws.Cells[r, 6].Value = j.Description; ws.Cells[r, 6].Value = j.Description;
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 7].Value = j.ProjectName;
ws.Cells[r, 8].Value = j.FinalPrice; ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 9].Value = j.CreatedAt.ToString("yyyy-MM-dd"); ws.Cells[r, 9].Value = j.FinalPrice;
ws.Cells[r, 10].Value = j.CreatedAt.ToString("yyyy-MM-dd");
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -353,7 +385,7 @@ public class AccountDataExportController : Controller
var data = await FetchQuotesAsync(companyId); var data = await FetchQuotesAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Quotes"); var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status", var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" }; "Quote Date", "Expiration Date", "Project Name", "Subtotal", "Tax", "Total" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
{ {
@@ -363,7 +395,8 @@ public class AccountDataExportController : Controller
ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString(); ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString();
ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd"); ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = q.SubTotal; ws.Cells[r, 8].Value = q.TaxAmount; ws.Cells[r, 9].Value = q.Total; ws.Cells[r, 7].Value = q.ProjectName;
ws.Cells[r, 8].Value = q.SubTotal; ws.Cells[r, 9].Value = q.TaxAmount; ws.Cells[r, 10].Value = q.Total;
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -377,7 +410,7 @@ public class AccountDataExportController : Controller
var data = await FetchInvoicesAsync(companyId); var data = await FetchInvoicesAsync(companyId);
var ws = pkg.Workbook.Worksheets.Add("Invoices"); var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date", var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" }; "Due Date", "Project Name", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
{ {
@@ -389,9 +422,10 @@ public class AccountDataExportController : Controller
ws.Cells[r, 3].Value = cust; ws.Cells[r, 4].Value = inv.Status.ToString(); ws.Cells[r, 3].Value = cust; ws.Cells[r, 4].Value = inv.Status.ToString();
ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd"); ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = inv.SubTotal; ws.Cells[r, 8].Value = inv.TaxAmount; ws.Cells[r, 7].Value = inv.ProjectName;
ws.Cells[r, 9].Value = inv.Total; ws.Cells[r, 10].Value = inv.AmountPaid; ws.Cells[r, 8].Value = inv.SubTotal; ws.Cells[r, 9].Value = inv.TaxAmount;
ws.Cells[r, 11].Value = inv.BalanceDue; ws.Cells[r, 10].Value = inv.Total; ws.Cells[r, 11].Value = inv.AmountPaid;
ws.Cells[r, 12].Value = inv.BalanceDue;
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -487,15 +521,30 @@ public class AccountDataExportController : Controller
{ {
var data = await FetchCustomersAsync(companyId); var data = await FetchCustomersAsync(companyId);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes"); sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes,LeadSource,ShipToAddress,ShipToCity,ShipToState,ShipToZipCode,ShipToCountry");
foreach (var c in data) foreach (var c in data)
{ {
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial"; var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial";
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)}"); sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)},{CsvEscape(c.LeadSource)},{CsvEscape(c.ShipToAddress)},{CsvEscape(c.ShipToCity)},{CsvEscape(c.ShipToState)},{CsvEscape(c.ShipToZipCode)},{CsvEscape(c.ShipToCountry)}");
} }
return sb.ToString(); return sb.ToString();
} }
/// <summary>Builds the customer contacts CSV. CustomerEmail is the join key for re-import.</summary>
private async Task<string> BuildCustomerContactsCsv(int companyId)
{
var data = await _db.CustomerContacts.AsNoTracking()
.Include(cc => cc.Customer)
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
.ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("CustomerEmail,FirstName,LastName,Title,ContactRole,Email,Phone,MobilePhone,Notes");
foreach (var cc in data)
sb.AppendLine($"{CsvEscape(cc.Customer?.Email)},{CsvEscape(cc.FirstName)},{CsvEscape(cc.LastName)},{CsvEscape(cc.Title)},{CsvEscape(cc.ContactRole)},{CsvEscape(cc.Email)},{CsvEscape(cc.Phone)},{CsvEscape(cc.MobilePhone)},{CsvEscape(cc.Notes)}");
return sb.ToString();
}
/// <summary> /// <summary>
/// Column names match <c>JobImportDto</c> exactly so the file can be re-imported. /// Column names match <c>JobImportDto</c> exactly so the file can be re-imported.
/// CustomerEmail is used (not display name) because the importer resolves the customer FK by email. /// CustomerEmail is used (not display name) because the importer resolves the customer FK by email.
@@ -504,13 +553,13 @@ public class AccountDataExportController : Controller
{ {
var data = await FetchJobsAsync(companyId); var data = await FetchJobsAsync(companyId);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes"); sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,ProjectName,FinalPrice,CustomerPO,SpecialInstructions,Notes");
foreach (var j in data) foreach (var j in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName ? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(); : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}"); sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(j.ProjectName)},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
} }
return sb.ToString(); return sb.ToString();
} }
@@ -520,13 +569,13 @@ public class AccountDataExportController : Controller
{ {
var data = await FetchQuotesAsync(companyId); var data = await FetchQuotesAsync(companyId);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions"); sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,ProjectName,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
foreach (var q in data) foreach (var q in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
? q.Customer.CompanyName ? q.Customer.CompanyName
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim(); : $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}"); sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{CsvEscape(q.ProjectName)},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
} }
return sb.ToString(); return sb.ToString();
} }
@@ -539,13 +588,13 @@ public class AccountDataExportController : Controller
{ {
var data = await FetchInvoicesAsync(companyId); var data = await FetchInvoicesAsync(companyId);
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due"); sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Project Name,Subtotal,Tax,Total,Amount Paid,Balance Due");
foreach (var inv in data) foreach (var inv in data)
{ {
var cust = inv.Customer != null var cust = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim()) ? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}"; : $"Customer #{inv.CustomerId}";
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}"); sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(inv.ProjectName)},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
} }
return sb.ToString(); return sb.ToString();
} }
@@ -81,7 +81,7 @@ public class AccountingExportController : Controller
.OrderBy(e => e.Date) .OrderBy(e => e.Date)
.ToList(); .ToList();
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end); var bills = await _unitOfWork.Bills.GetForDateRangeAsync(companyId, start, end);
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)) var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactFirstName) .OrderBy(c => c.CompanyName ?? c.ContactFirstName)
@@ -33,8 +33,7 @@ public class AiUsageReportController : Controller
{ {
try try
{ {
var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true)) var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
.Where(c => !c.IsDeleted)
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive }) .Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
.ToList(); .ToList();
@@ -35,6 +35,7 @@ public class BillsController : Controller
private readonly IAzureBlobStorageService _blobStorage; private readonly IAzureBlobStorageService _blobStorage;
private readonly StorageSettings _storageSettings; private readonly StorageSettings _storageSettings;
private readonly IAiUsageLogger _usageLogger; private readonly IAiUsageLogger _usageLogger;
private readonly ITenantContext _tenantContext;
public BillsController( public BillsController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
@@ -45,7 +46,8 @@ public class BillsController : Controller
IAccountingAiService accountingAi, IAccountingAiService accountingAi,
IAzureBlobStorageService blobStorage, IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> storageSettings, IOptions<StorageSettings> storageSettings,
IAiUsageLogger usageLogger) IAiUsageLogger usageLogger,
ITenantContext tenantContext)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
@@ -56,6 +58,7 @@ public class BillsController : Controller
_blobStorage = blobStorage; _blobStorage = blobStorage;
_storageSettings = storageSettings.Value; _storageSettings = storageSettings.Value;
_usageLogger = usageLogger; _usageLogger = usageLogger;
_tenantContext = tenantContext;
} }
// -- Index ---------------------------------------------------------------- // -- Index ----------------------------------------------------------------
@@ -64,7 +67,7 @@ public class BillsController : Controller
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/> /// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null). /// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
/// Expenses are inherently fully paid so they are always excluded when the caller filters to /// Expenses are inherently fully paid so they are always excluded when the caller filters to
/// "Unpaid" or "Overdue" preventing them from inflating the "amount owed" summary. /// "Unpaid" or "Overdue" preventing them from inflating the "amount owed" summary.
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally. /// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary> /// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25) public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
@@ -81,10 +84,12 @@ public class BillsController : Controller
searchAmount = parsed; searchAmount = parsed;
} }
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Bills // Bills
if (type == null || type == "Bill") if (type == null || type == "Bill")
{ {
var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount); var bills = await _unitOfWork.Bills.GetForIndexAsync(companyId, status, search, searchAmount);
entries.AddRange(bills.Select(b => new BillExpenseListDto entries.AddRange(bills.Select(b => new BillExpenseListDto
{ {
@@ -112,7 +117,7 @@ public class BillsController : Controller
})); }));
} }
// Expenses are always fully paid exclude when filtering to unpaid/overdue bills only // Expenses are always fully paid exclude when filtering to unpaid/overdue bills only
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue") if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{ {
var expSearch = search; var expSearch = search;
@@ -166,7 +171,7 @@ public class BillsController : Controller
/// <summary> /// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in /// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed earlier states mean /// <c>Received</c> or <c>PartiallyReceived</c> status can be billed earlier states mean
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for /// goods have not yet arrived and no liability has been incurred. If a bill already exists for
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries. /// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
/// Line items are copied from PO items (using inventory item names where available), and /// Line items are copied from PO items (using inventory item names where available), and
@@ -291,7 +296,7 @@ public class BillsController : Controller
/// review before committing to AP. Empty line items (zero account or zero price) are stripped /// review before committing to AP. Empty line items (zero account or zero price) are stripped
/// before validation to avoid spurious errors when the browser submits blank rows. /// before validation to avoid spurious errors when the browser submits blank rows.
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted /// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c> /// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c>
/// useful for entering historical bills that were already settled. Account balance side /// useful for entering historical bills that were already settled. Account balance side
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not /// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
/// affect the AP ledger until they are approved. If the bill was created from a PO the /// affect the AP ledger until they are approved. If the bill was created from a PO the
@@ -322,7 +327,7 @@ public class BillsController : Controller
{ {
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
// Period lock check block if the bill date is in a locked period // Period lock check block if the bill date is in a locked period
if (currentUser != null) if (currentUser != null)
{ {
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -399,7 +404,7 @@ public class BillsController : Controller
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
}); });
// Receipt upload after the transaction commits bill.Id is set and core data // Receipt upload after the transaction commits bill.Id is set and core data
// is secure. A blob failure here leaves the bill intact without an attachment. // is secure. A blob failure here leaves the bill intact without an attachment.
if (receiptFile != null && receiptFile.Length > 0) if (receiptFile != null && receiptFile.Length > 0)
{ {
@@ -439,7 +444,8 @@ public class BillsController : Controller
{ {
if (id == null) return NotFound(); if (id == null) return NotFound();
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value, companyId);
if (bill == null) return NotFound(); if (bill == null) return NotFound();
var dto = _mapper.Map<BillDto>(bill); var dto = _mapper.Map<BillDto>(bill);
@@ -454,7 +460,7 @@ public class BillsController : Controller
.ToList(); .ToList();
ViewBag.BankAccounts = bankAccounts ViewBag.BankAccounts = bankAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString())) .Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList(); .ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>() ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
@@ -477,7 +483,8 @@ public class BillsController : Controller
{ {
if (id == null) return NotFound(); if (id == null) return NotFound();
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value, companyId);
if (bill == null) return NotFound(); if (bill == null) return NotFound();
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided) if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
@@ -540,7 +547,8 @@ public class BillsController : Controller
try try
{ {
var bill = await _unitOfWork.Bills.LoadForEditAsync(id); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForEditAsync(id, companyId);
if (bill == null) return NotFound(); if (bill == null) return NotFound();
@@ -867,7 +875,7 @@ public class BillsController : Controller
/// <summary> /// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger. /// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account any payments /// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account any payments
/// already recorded remain as historical cash transactions. The vendor balance is likewise /// already recorded remain as historical cash transactions. The vendor balance is likewise
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without /// reduced only by the outstanding balance, not the total. To signal "fully settled" without
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c> /// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
@@ -968,7 +976,8 @@ public class BillsController : Controller
private async Task<string> GenerateBillNumberAsync() private async Task<string> GenerateBillNumberAsync()
{ {
var prefix = $"BILL-{DateTime.Now:yyMM}-"; var prefix = $"BILL-{DateTime.Now:yyMM}-";
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(companyId, prefix);
int next = 1; int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num)) if (last != null && int.TryParse(last[prefix.Length..], out int num))
@@ -979,13 +988,14 @@ public class BillsController : Controller
/// <summary> /// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>. /// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> soft-deleted /// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> soft-deleted
/// records are included in the scan so payment numbers are never reused. /// records are included in the scan so payment numbers are never reused.
/// </summary> /// </summary>
private async Task<string> GeneratePaymentNumberAsync() private async Task<string> GeneratePaymentNumberAsync()
{ {
var prefix = $"BPMT-{DateTime.Now:yyMM}-"; var prefix = $"BPMT-{DateTime.Now:yyMM}-";
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(companyId, prefix);
int next = 1; int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num)) if (last != null && int.TryParse(last[prefix.Length..], out int num))
@@ -1139,13 +1149,13 @@ public class BillsController : Controller
// -- AI: Recurring Bill Detection ------------------------------------------ // -- AI: Recurring Bill Detection ------------------------------------------
/// <summary> /// <summary>
/// GET page displays the recurring bill detection tool. No data is pre-fetched here; /// GET page displays the recurring bill detection tool. No data is pre-fetched here;
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>. /// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary> /// </summary>
public IActionResult RecurringDetection() => View(); public IActionResult RecurringDetection() => View();
/// <summary> /// <summary>
/// AJAX POST loads up to 12 months of bill history for the company and passes it to /// AJAX POST loads up to 12 months of bill history for the company and passes it to
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are /// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
/// included; Voided bills are excluded so cancelled payments do not distort the pattern. /// included; Voided bills are excluded so cancelled payments do not distort the pattern.
/// Results are returned as JSON for client-side rendering in the view. /// Results are returned as JSON for client-side rendering in the view.
@@ -181,6 +181,8 @@ public class CompanySettingsController : Controller
? (DateTime?)company.BookLockedThrough.Value.ToLocalTime() ? (DateTime?)company.BookLockedThrough.Value.ToLocalTime()
: null; : null;
ViewBag.IsDemoCompany = company.CompanyCode == "DEMO";
return View(dto); return View(dto);
} }
catch (FormatException fex) catch (FormatException fex)
@@ -230,11 +232,19 @@ public class CompanySettingsController : Controller
return Json(new { success = false, message = "Company not found." }); return Json(new { success = false, message = "Company not found." });
} }
// Update company properties // Explicit assignment avoids AutoMapper quirks with tracked EF entities
_mapper.Map(dto, company); company.CompanyName = dto.CompanyName.Trim();
company.UpdatedAt = DateTime.UtcNow; company.CompanyCode = string.IsNullOrWhiteSpace(dto.CompanyCode) ? null : dto.CompanyCode.Trim();
company.PrimaryContactName = dto.PrimaryContactName.Trim();
company.PrimaryContactEmail = dto.PrimaryContactEmail.Trim();
company.Phone = string.IsNullOrWhiteSpace(dto.Phone) ? null : dto.Phone.Trim();
company.Address = string.IsNullOrWhiteSpace(dto.Address) ? null : dto.Address.Trim();
company.City = string.IsNullOrWhiteSpace(dto.City) ? null : dto.City.Trim();
company.State = string.IsNullOrWhiteSpace(dto.State) ? null : dto.State.Trim();
company.ZipCode = string.IsNullOrWhiteSpace(dto.ZipCode) ? null : dto.ZipCode.Trim();
company.TimeZone = string.IsNullOrWhiteSpace(dto.TimeZone) ? null : dto.TimeZone.Trim();
company.AccountingMethod = dto.AccountingMethod;
await _unitOfWork.Companies.UpdateAsync(company);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} settings updated by user", companyId); _logger.LogInformation("Company {CompanyId} settings updated by user", companyId);
@@ -1726,6 +1736,26 @@ public class CompanySettingsController : Controller
#region Blast Setups #region Blast Setups
/// <summary>
/// Single authoritative blast-rate calculation endpoint. Takes equipment parameters and
/// returns the sqft/hr rate using the same ShopCapabilityCalculator formula the AI uses.
/// The modal live preview calls this instead of duplicating the formula in JavaScript.
/// </summary>
[HttpGet]
public IActionResult DeriveBlastRate(decimal cfm, int nozzle, int setupType, int substrate, decimal? rateOverride)
{
var setup = new CompanyBlastSetup
{
CompressorCfm = cfm,
BlastNozzleSize = nozzle,
SetupType = (BlastSetupType)setupType,
PrimarySubstrate = (BlastSubstrateType)substrate,
BlastRateSqFtPerHourOverride = rateOverride > 0 ? rateOverride : null
};
var rate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(setup);
return Json(new { rate });
}
/// <summary>Returns all active blast setups for the current company with their derived rates.</summary> /// <summary>Returns all active blast setups for the current company with their derived rates.</summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetBlastSetups() public async Task<IActionResult> GetBlastSetups()
@@ -3043,6 +3073,151 @@ public class CompanySettingsController : Controller
return Json(new { success = true, templates = dtos }); return Json(new { success = true, templates = dtos });
} }
/// <summary>Downloads all formula templates as a portable JSON backup file.</summary>
[HttpGet]
public async Task<IActionResult> ExportCustomItemTemplates()
{
if (!AllowCustomFormulas()) return Forbid();
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var templates = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
// Parse FieldsJson into a real JsonElement so it is embedded as a proper JSON array
// in the export file rather than as an escaped string. This makes the file human-readable
// and avoids round-trip corruption when files are manually edited.
static System.Text.Json.JsonElement ParseFields(string? raw)
{
try { return System.Text.Json.JsonDocument.Parse(raw ?? "[]").RootElement.Clone(); }
catch { return System.Text.Json.JsonDocument.Parse("[]").RootElement.Clone(); }
}
var export = new
{
exportedAt = DateTime.UtcNow,
version = 1,
templates = templates
.OrderBy(t => t.DisplayOrder).ThenBy(t => t.Name)
.Select(t => new
{
t.Name,
t.Description,
t.OutputMode,
Fields = ParseFields(t.FieldsJson),
t.Formula,
t.DefaultRate,
t.RateLabel,
t.Notes,
t.DisplayOrder,
t.IsActive
})
};
var json = System.Text.Json.JsonSerializer.Serialize(export,
new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase
});
var filename = $"formula-templates-{DateTime.UtcNow:yyyyMMdd}.json";
return File(System.Text.Encoding.UTF8.GetBytes(json), "application/json", filename);
}
/// <summary>
/// Imports formula templates from a JSON backup file produced by ExportCustomItemTemplates.
/// Templates whose name already exists in the company are skipped; all others are created.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ImportCustomItemTemplates(IFormFile file)
{
if (!AllowCustomFormulas()) return Json(new { success = false, message = "Custom Formulas are not available on your current plan." });
if (file == null || file.Length == 0) return Json(new { success = false, message = "No file selected." });
if (!file.FileName.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
return Json(new { success = false, message = "File must be a .json export file." });
if (file.Length > 512 * 1024)
return Json(new { success = false, message = "File is too large (max 512 KB)." });
string json;
using (var reader = new System.IO.StreamReader(file.OpenReadStream()))
json = await reader.ReadToEndAsync();
System.Text.Json.JsonElement root;
try
{
root = System.Text.Json.JsonDocument.Parse(json).RootElement;
}
catch
{
return Json(new { success = false, message = "Could not parse file — make sure it is a valid formula export." });
}
if (!root.TryGetProperty("templates", out var templatesEl) || templatesEl.ValueKind != System.Text.Json.JsonValueKind.Array)
return Json(new { success = false, message = "Invalid export format: missing \"templates\" array." });
var companyId = _tenantContext.GetCurrentCompanyId()!.Value;
var existing = await _unitOfWork.CustomItemTemplates.FindAsync(t => t.CompanyId == companyId);
// Track names already in DB + names imported within this same file to prevent intra-file duplicates
var usedNames = existing.Select(t => t.Name.ToLowerInvariant()).ToHashSet();
int imported = 0, skipped = 0;
var skippedNames = new List<string>();
var errors = new List<string>();
foreach (var item in templatesEl.EnumerateArray())
{
try
{
var name = item.TryGetProperty("name", out var nEl) ? nEl.GetString() ?? "" : "";
if (string.IsNullOrWhiteSpace(name)) { errors.Add("Skipped one template with no name."); continue; }
if (usedNames.Contains(name.ToLowerInvariant()))
{
skipped++;
skippedNames.Add(name);
continue;
}
var dto = new CreateCustomItemTemplateDto
{
Name = name,
Description = item.TryGetProperty("description", out var d) ? d.GetString() : null,
OutputMode = item.TryGetProperty("outputMode", out var om) ? om.GetString() ?? "FixedRate" : "FixedRate",
// "fields" is a real JSON array in the export; GetRawText() reconstructs the string
FieldsJson = item.TryGetProperty("fields", out var fj) ? fj.GetRawText() : "[]",
Formula = item.TryGetProperty("formula", out var f) ? f.GetString() ?? "" : "",
DefaultRate = item.TryGetProperty("defaultRate", out var dr) && dr.ValueKind == System.Text.Json.JsonValueKind.Number ? dr.GetDecimal() : null,
RateLabel = item.TryGetProperty("rateLabel", out var rl) ? rl.GetString() : null,
Notes = item.TryGetProperty("notes", out var n) ? n.GetString() : null,
DisplayOrder = item.TryGetProperty("displayOrder", out var dord) && dord.ValueKind == System.Text.Json.JsonValueKind.Number ? dord.GetInt32() : 0,
IsActive = true,
};
var fieldError = ValidateTemplateFields(dto.FieldsJson);
if (fieldError != null) { errors.Add($"\"{name}\": {fieldError}"); continue; }
var (normalizedFormula, formulaError) = _formulaAiService.NormalizeAndValidate(dto.Formula);
if (formulaError != null) { errors.Add($"\"{name}\": formula error &mdash; {formulaError}"); continue; }
dto.Formula = normalizedFormula;
var entity = _mapper.Map<CustomItemTemplate>(dto);
entity.CompanyId = companyId;
entity.CreatedAt = DateTime.UtcNow;
await _unitOfWork.CustomItemTemplates.AddAsync(entity);
usedNames.Add(name.ToLowerInvariant());
imported++;
}
catch (Exception ex)
{
errors.Add($"Unexpected error on one template: {ex.Message}");
}
}
if (imported > 0)
await _unitOfWork.CompleteAsync();
return Json(new { success = true, imported, skipped, skippedNames, errors });
}
/// <summary>Creates a new formula template for the current company.</summary> /// <summary>Creates a new formula template for the current company.</summary>
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto) public async Task<IActionResult> CreateCustomItemTemplate([FromBody] CreateCustomItemTemplateDto dto)
@@ -91,7 +91,9 @@ public class CustomersController : Controller
// Build orderBy function // Build orderBy function
Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch Func<IQueryable<Customer>, IOrderedQueryable<Customer>> orderBy = gridRequest.SortColumn switch
{ {
"CompanyName" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CompanyName) : q.OrderByDescending(c => c.CompanyName), "CompanyName" => q => gridRequest.SortDirection == "asc"
? q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
: q.OrderByDescending(c => c.CompanyName ?? c.ContactLastName),
"ContactName" => q => gridRequest.SortDirection == "asc" "ContactName" => q => gridRequest.SortDirection == "asc"
? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName) ? q.OrderBy(c => c.ContactFirstName).ThenBy(c => c.ContactLastName)
: q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName), : q.OrderByDescending(c => c.ContactFirstName).ThenByDescending(c => c.ContactLastName),
@@ -100,7 +102,7 @@ public class CustomersController : Controller
"CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance), "CurrentBalance" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.CurrentBalance) : q.OrderByDescending(c => c.CurrentBalance),
"IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive), "IsActive" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.IsActive) : q.OrderByDescending(c => c.IsActive),
"LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate), "LastContactDate" => q => gridRequest.SortDirection == "asc" ? q.OrderBy(c => c.LastContactDate) : q.OrderByDescending(c => c.LastContactDate),
_ => q => q.OrderBy(c => c.CompanyName) _ => q => q.OrderBy(c => c.CompanyName ?? c.ContactLastName)
}; };
// Get paged data // Get paged data
@@ -144,9 +146,11 @@ public class CustomersController : Controller
} }
/// <summary> /// <summary>
/// Renders the customer detail page, including the 10 most-recent non-voided credit memos. /// Renders the customer detail page. In addition to basic info and credit memos, runs
/// Credit memos are loaded separately (not via eager loading) because the customer entity /// four sequential queries (jobs, quotes, invoices, deposits) to build:
/// does not navigate to CreditMemo; this keeps the Customer aggregate lean. /// (1) <see cref="CustomerLifetimeStatsDto"/> — aggregate KPIs for the stats card
/// (2) <see cref="CustomerTimelineEventDto"/> list — last 15 events for the activity feed
/// Credit memos are loaded separately because the Customer aggregate does not navigate to them.
/// </summary> /// </summary>
public async Task<IActionResult> Details(int? id) public async Task<IActionResult> Details(int? id)
{ {
@@ -170,6 +174,120 @@ public class CustomersController : Controller
.Take(10) .Take(10)
.ToList(); .ToList();
// CRM queries — must be sequential; EF Core's DbContext is not thread-safe
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CustomerId == id.Value && j.CompanyId == companyId, false, j => j.JobStatus)).ToList();
var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CustomerId == id.Value && q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var invoices = (await _unitOfWork.Invoices.FindAsync(i => i.CustomerId == id.Value && i.CompanyId == companyId)).ToList();
var deposits = (await _unitOfWork.Deposits.FindAsync(d => d.CustomerId == id.Value && d.CompanyId == companyId)).ToList();
var pendingPickups = (await _unitOfWork.Jobs.FindAsync(
j => j.CustomerId == id.Value && j.CompanyId == companyId
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup,
false, j => j.JobStatus))
.OrderBy(j => j.UpdatedAt)
.ToList();
ViewBag.PendingPickups = pendingPickups;
var customerNotes = (await _unitOfWork.CustomerNotes.FindAsync(n => n.CustomerId == id.Value))
.OrderByDescending(n => n.CreatedAt)
.ToList();
ViewBag.CustomerNotes = customerNotes;
var preferredPowders = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id.Value, false, p => p.InventoryItem))
.ToList();
ViewBag.PreferredPowders = preferredPowders;
var customerContacts = (await _unitOfWork.CustomerContacts.FindAsync(n => n.CustomerId == id.Value))
.OrderBy(c => c.FirstName)
.ToList();
ViewBag.CustomerContacts = customerContacts;
// Stats
var nonVoided = invoices.Where(i => i.Status != InvoiceStatus.Voided).ToList();
var stats = new CustomerLifetimeStatsDto
{
TotalJobs = jobs.Count,
ActiveJobs = jobs.Count(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus),
TotalRevenue = nonVoided.Sum(i => i.Total),
TotalCollected = nonVoided.Sum(i => i.AmountPaid),
AverageJobValue = jobs.Count > 0 ? jobs.Average(j => j.FinalPrice) : 0,
LastJobDate = jobs.Count > 0 ? jobs.Max(j => (DateTime?)j.CreatedAt) : null,
LastJobId = jobs.Count > 0 ? jobs.OrderByDescending(j => j.CreatedAt).First().Id : (int?)null,
TotalQuotes = quotes.Count,
TotalInvoices = invoices.Count,
OpenBalance = customer.CurrentBalance
};
stats.DaysSinceLastJob = stats.LastJobDate.HasValue
? (int)(DateTime.UtcNow - stats.LastJobDate.Value).TotalDays
: null;
// Timeline: merge all event types, sort descending, cap at 15
var events = new List<CustomerTimelineEventDto>();
foreach (var j in jobs)
events.Add(new CustomerTimelineEventDto
{
Date = j.CreatedAt,
Icon = "bi-briefcase",
BadgeColor = "primary",
Title = $"Job {j.JobNumber}",
Subtitle = j.Description,
Amount = j.FinalPrice > 0 ? j.FinalPrice : null,
EntityId = j.Id,
LinkController = "Jobs",
LinkAction = "Details"
});
foreach (var q in quotes)
events.Add(new CustomerTimelineEventDto
{
Date = q.QuoteDate,
Icon = "bi-file-text",
BadgeColor = "info",
Title = $"Quote {q.QuoteNumber}",
Subtitle = q.QuoteStatus?.DisplayName,
Amount = q.Total > 0 ? q.Total : null,
EntityId = q.Id,
LinkController = "Quotes",
LinkAction = "Details"
});
foreach (var inv in invoices)
events.Add(new CustomerTimelineEventDto
{
Date = inv.InvoiceDate,
Icon = inv.Status == InvoiceStatus.Paid ? "bi-receipt-cutoff" : "bi-receipt",
BadgeColor = inv.Status == InvoiceStatus.Paid ? "success" : "warning",
Title = $"Invoice {inv.InvoiceNumber}",
Subtitle = inv.Status.ToString(),
Amount = inv.Total,
EntityId = inv.Id,
LinkController = "Invoices",
LinkAction = "Details"
});
foreach (var dep in deposits)
events.Add(new CustomerTimelineEventDto
{
Date = dep.ReceivedDate,
Icon = "bi-cash-coin",
BadgeColor = "success",
Title = "Deposit received",
Subtitle = dep.ReceiptNumber,
Amount = dep.Amount,
EntityId = dep.JobId,
LinkController = dep.JobId.HasValue ? "Jobs" : null,
LinkAction = dep.JobId.HasValue ? "Details" : null
});
ViewBag.CrmStats = stats;
ViewBag.Timeline = events
.OrderByDescending(e => e.Date)
.Take(15)
.ToList();
var customerDto = _mapper.Map<CustomerDto>(customer); var customerDto = _mapper.Map<CustomerDto>(customer);
return View(customerDto); return View(customerDto);
} }
@@ -938,6 +1056,308 @@ public class CustomersController : Controller
return RedirectToAction(nameof(Details), new { id }); return RedirectToAction(nameof(Details), new { id });
} }
/// <summary>
/// Adds a quick internal note to the customer record. Returns the rendered note HTML so
/// the caller can prepend it to the notes list without a full page reload.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddCustomerNote(int id, string note, bool isImportant = false)
{
if (string.IsNullOrWhiteSpace(note))
return Json(new { success = false, message = "Note cannot be empty." });
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var currentUser = await _userManager.GetUserAsync(User);
var entity = new PowderCoating.Core.Entities.CustomerNote
{
CustomerId = id,
Note = note.Trim(),
IsImportant = isImportant,
CreatedBy = currentUser?.Email
};
await _unitOfWork.CustomerNotes.AddAsync(entity);
await _unitOfWork.CompleteAsync();
var displayDate = entity.CreatedAt.ToLocalTime().ToString("MMM dd, yyyy h:mm tt");
var author = currentUser?.Email ?? "Staff";
var noteHtml = $@"<div class=""customer-note-item d-flex gap-2 py-2 border-bottom"" data-note-id=""{entity.Id}"">
<div class=""flex-grow-1"">
{(isImportant ? @"<span class=""text-warning me-1"" title=""Important"">&#9733;</span>" : "")}
<span class=""note-text"">{System.Web.HttpUtility.HtmlEncode(entity.Note)}</span>
<div class=""text-muted"" style=""font-size:0.75rem;"">{System.Web.HttpUtility.HtmlEncode(author)} &mdash; {displayDate}</div>
</div>
<button type=""button"" class=""btn btn-sm btn-link text-danger p-0 flex-shrink-0""
onclick=""deleteCustomerNote({id}, {entity.Id})"" title=""Delete note"">
<i class=""bi bi-trash""></i>
</button>
</div>";
return Json(new { success = true, noteHtml });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding note to customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Soft-deletes a single customer note. Only the owning company can delete their own notes
/// (enforced via CompanyId on the entity + global query filter).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteCustomerNote(int id, int noteId)
{
try
{
var note = await _unitOfWork.CustomerNotes.GetByIdAsync(noteId);
if (note == null || note.CustomerId != id)
return Json(new { success = false, message = "Note not found." });
await _unitOfWork.CustomerNotes.SoftDeleteAsync(note);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting note {NoteId} for customer {CustomerId}", noteId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Returns up to 10 inventory items matching the search term for the preferred-powder typeahead.
/// Results are scoped to the current company and only include active items.
/// </summary>
[HttpGet]
public async Task<IActionResult> SearchInventoryItems(string term)
{
if (string.IsNullOrWhiteSpace(term) || term.Length < 2)
return Json(Array.Empty<object>());
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var lower = term.ToLower();
var items = (await _unitOfWork.InventoryItems.FindAsync(
i => i.CompanyId == companyId && i.IsActive
&& (i.Name.ToLower().Contains(lower) || (i.SKU != null && i.SKU.ToLower().Contains(lower)))))
.OrderBy(i => i.Name)
.Take(10)
.Select(i => new { i.Id, i.Name, i.ColorName, sku = i.SKU })
.ToList();
return Json(items);
}
/// <summary>
/// Associates an inventory item as a preferred powder for a customer.
/// Silently succeeds if the association already exists (idempotent).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddPreferredPowder(int id, int inventoryItemId, string? notes = null)
{
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var existing = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id && p.InventoryItemId == inventoryItemId)).FirstOrDefault();
if (existing != null)
return Json(new { success = false, message = $"{item.Name} is already in preferred powders." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
await _unitOfWork.CustomerPreferredPowders.AddAsync(new PowderCoating.Core.Entities.CustomerPreferredPowder
{
CustomerId = id,
InventoryItemId = inventoryItemId,
Notes = notes?.Trim(),
CompanyId = companyId
});
await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemId = inventoryItemId, itemName = item.Name, notes = notes?.Trim() });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding preferred powder for customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Removes a preferred-powder association by inventory item ID. Soft-deletes the record
/// so the history is preserved but it no longer appears on the customer page.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> RemovePreferredPowder(int id, int itemId)
{
try
{
var record = (await _unitOfWork.CustomerPreferredPowders.FindAsync(
p => p.CustomerId == id && p.InventoryItemId == itemId)).FirstOrDefault();
if (record == null) return Json(new { success = false, message = "Record not found." });
await _unitOfWork.CustomerPreferredPowders.SoftDeleteAsync(record);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error removing preferred powder {ItemId} for customer {CustomerId}", itemId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
// ── Customer Contacts ──────────────────────────────────────────────────
/// <summary>
/// Returns the JSON representation of a single contact for pre-populating the edit modal.
/// </summary>
[HttpGet]
public async Task<IActionResult> GetContact(int id, int contactId)
{
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
if (contact == null || contact.CustomerId != id)
return Json(new { success = false });
var dto = _mapper.Map<PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto>(contact);
return Json(new { success = true, contact = dto });
}
/// <summary>
/// Adds a new contact to the customer record. Returns rendered row HTML so the
/// caller can append it to the contacts table without a full reload.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> AddContact(int id, PowderCoating.Application.DTOs.Customer.CreateCustomerContactDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
try
{
var customer = await _unitOfWork.Customers.GetByIdAsync(id);
if (customer == null) return Json(new { success = false, message = "Customer not found." });
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var entity = _mapper.Map<PowderCoating.Core.Entities.CustomerContact>(dto);
entity.CustomerId = id;
entity.CompanyId = companyId;
await _unitOfWork.CustomerContacts.AddAsync(entity);
await _unitOfWork.CompleteAsync();
var rowHtml = BuildContactRowHtml(id, entity);
return Json(new { success = true, contactId = entity.Id, rowHtml });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error adding contact to customer {CustomerId}", id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Updates an existing contact record in place. Returns the updated row HTML.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateContact(int id, PowderCoating.Application.DTOs.Customer.UpdateCustomerContactDto dto)
{
if (!ModelState.IsValid)
return Json(new { success = false, message = ModelState.Values.SelectMany(v => v.Errors).FirstOrDefault()?.ErrorMessage ?? "Invalid data." });
try
{
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(dto.Id);
if (contact == null || contact.CustomerId != id)
return Json(new { success = false, message = "Contact not found." });
_mapper.Map(dto, contact);
contact.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.CustomerContacts.UpdateAsync(contact);
await _unitOfWork.CompleteAsync();
var rowHtml = BuildContactRowHtml(id, contact);
return Json(new { success = true, contactId = contact.Id, rowHtml });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error updating contact {ContactId} for customer {CustomerId}", dto.Id, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Soft-deletes a contact. Only the owning company can delete their contacts
/// (enforced via CompanyId + global query filter).
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteContact(int id, int contactId)
{
try
{
var contact = await _unitOfWork.CustomerContacts.GetByIdAsync(contactId);
if (contact == null || contact.CustomerId != id)
return Json(new { success = false, message = "Contact not found." });
await _unitOfWork.CustomerContacts.SoftDeleteAsync(contact);
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting contact {ContactId} for customer {CustomerId}", contactId, id);
return Json(new { success = false, message = "An error occurred." });
}
}
/// <summary>
/// Builds the table-row HTML for a contact. Kept server-side so the same markup is
/// used for both the initial page render and the AJAX insert/replace path.
/// </summary>
private static string BuildContactRowHtml(int customerId, PowderCoating.Core.Entities.CustomerContact c)
{
var displayName = string.IsNullOrWhiteSpace(c.LastName) ? c.FirstName : $"{c.FirstName} {c.LastName}";
var titlePart = !string.IsNullOrWhiteSpace(c.Title) ? System.Web.HttpUtility.HtmlEncode(c.Title) : "";
var roleBadge = !string.IsNullOrWhiteSpace(c.ContactRole)
? $"<span class=\"badge bg-secondary bg-opacity-10 text-secondary ms-1\">{System.Web.HttpUtility.HtmlEncode(c.ContactRole)}</span>"
: "";
var email = !string.IsNullOrWhiteSpace(c.Email)
? $"<a href=\"mailto:{System.Web.HttpUtility.HtmlEncode(c.Email)}\" class=\"text-decoration-none small\">{System.Web.HttpUtility.HtmlEncode(c.Email)}</a>"
: "<span class=\"text-muted small\">&mdash;</span>";
var phone = !string.IsNullOrWhiteSpace(c.Phone ?? c.MobilePhone)
? $"<span class=\"small\">{System.Web.HttpUtility.HtmlEncode(c.Phone ?? c.MobilePhone)}</span>"
: "<span class=\"text-muted small\">&mdash;</span>";
return $@"<tr data-contact-id=""{c.Id}"">
<td>
<div class=""fw-semibold"">{System.Web.HttpUtility.HtmlEncode(displayName)}{roleBadge}</div>
{(string.IsNullOrWhiteSpace(titlePart) ? "" : $"<div class=\"text-muted\" style=\"font-size:0.75rem;\">{titlePart}</div>")}
</td>
<td>{email}</td>
<td>{phone}</td>
<td class=""text-end"">
<button type=""button"" class=""btn btn-sm btn-outline-secondary me-1""
onclick=""editContact({customerId}, {c.Id})"" title=""Edit"">
<i class=""bi bi-pencil""></i>
</button>
<button type=""button"" class=""btn btn-sm btn-outline-danger""
onclick=""deleteContact({customerId}, {c.Id})"" title=""Delete"">
<i class=""bi bi-trash""></i>
</button>
</td>
</tr>";
}
/// <summary> /// <summary>
/// Displays or downloads a dated activity statement for a customer. /// Displays or downloads a dated activity statement for a customer.
/// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view. /// Pass <c>pdf=true</c> to download the QuestPDF version; otherwise renders the HTML view.
@@ -266,7 +266,7 @@ public class DashboardController : Controller
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Powder orders needed // Powder orders needed
// --------------------------------------------------------------- // ---------------------------------------------------------------
var powderOrderGroups = MapPowderOrderGroups(data.PowderOrdersNeeded); var powderOrderGroups = MapPowderOrderGroupsMerged(data.PowderOrdersNeeded);
// --------------------------------------------------------------- // ---------------------------------------------------------------
// Powder orders placed // Powder orders placed
@@ -385,44 +385,53 @@ public class DashboardController : Controller
} }
/// <summary> /// <summary>
/// Marks a job-item coat as having its powder ordered. Called via AJAX from the Powder Orders /// Marks one or more job-item coats as having their powder ordered. Called via AJAX from
/// Needed panel. Verifies company ownership through the parent job (JobItemCoat has no direct /// the "Powder in Queue to be Ordered" panel. Accepts a comma-separated list of coat IDs
/// CompanyId) before updating <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and /// so that a merged line (multiple jobs needing the same powder) can be marked in one click.
/// <c>PowderOrderedByUserId</c> on the coat record. /// Verifies company ownership for each coat via its parent job before updating
/// <c>PowderOrdered</c>, <c>PowderOrderedAt</c>, and <c>PowderOrderedByUserId</c>.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> MarkPowderOrdered(int coatId) public async Task<IActionResult> MarkPowderOrdered(string coatIds)
{ {
try try
{ {
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId); var ids = coatIds?
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(s => int.TryParse(s.Trim(), out var id) ? id : 0)
.Where(id => id > 0)
.ToList() ?? new List<int>();
if (coat == null) if (ids.Count == 0)
return Json(new { success = false, message = "Coat not found." }); return Json(new { success = false, message = "No valid coat IDs provided." });
// JobItemCoat has no CompanyId — verify ownership via parent job var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
var parentCompanyId = coat.JobItem?.Job?.CompanyId; var results = new List<object>();
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
coat.PowderOrdered = true; foreach (var coatId in ids)
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await _unitOfWork.CompleteAsync();
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
return Json(new
{ {
success = true, var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
coat = new if (coat == null) continue;
// JobItemCoat has no CompanyId — verify ownership via parent job
var parentCompanyId = coat.JobItem?.Job?.CompanyId;
if (!_tenantContext.IsSuperAdmin() && parentCompanyId != _tenantContext.GetCurrentCompanyId())
continue;
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = currentUserId;
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
results.Add(new
{ {
coatId = coat.Id, coatId = coat.Id,
jobId = job?.Id, jobId = job?.Id,
jobNumber = job?.JobNumber, jobNumber = job?.JobNumber,
customerName = job?.Customer?.CompanyName, customerName = job?.Customer?.CompanyName ?? job?.Customer?.ContactFirstName ?? "Unknown",
colorName = coat.ColorName, colorName = coat.ColorName,
colorCode = coat.ColorCode, colorCode = coat.ColorCode,
finish = coat.Finish, finish = coat.Finish,
@@ -434,12 +443,16 @@ public class DashboardController : Controller
vendorId = vendor?.Id, vendorId = vendor?.Id,
vendorName = vendor?.CompanyName ?? "No Vendor Assigned", vendorName = vendor?.CompanyName ?? "No Vendor Assigned",
vendorPhone = vendor?.Phone vendorPhone = vendor?.Phone
} });
}); }
await _unitOfWork.CompleteAsync();
return Json(new { success = true, coats = results });
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error marking coat {CoatId} as powder ordered", coatId); _logger.LogError(ex, "Error marking coats {CoatIds} as powder ordered", coatIds);
return Json(new { success = false, message = "An error occurred." }); return Json(new { success = false, message = "An error occurred." });
} }
} }
@@ -876,6 +889,10 @@ public class DashboardController : Controller
} }
} }
/// <summary>
/// Projects per-coat rows into vendor-grouped DTOs one-line-per-coat.
/// Used for the "Awaiting Receipt" panel where each coat is received individually.
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups( private static List<PowderOrderVendorGroupDto> MapPowderOrderGroups(
IEnumerable<DashboardPowderOrderLineData> lines) => IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId) lines.GroupBy(l => l.VendorId)
@@ -892,10 +909,11 @@ public class DashboardController : Controller
TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m), TotalEstCost = g.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = g.Select(l => new PowderOrderLineDto Lines = g.Select(l => new PowderOrderLineDto
{ {
CoatId = l.CoatId, CoatIds = new List<int> { l.CoatId },
JobId = l.JobId, Jobs = new List<PowderOrderJobRefDto>
JobNumber = l.JobNumber, {
CustomerName = l.CustomerName, new() { JobId = l.JobId, JobNumber = l.JobNumber, CustomerName = l.CustomerName, LbsToOrder = l.LbsToOrder }
},
CoatName = l.CoatName, CoatName = l.CoatName,
ColorName = l.ColorName, ColorName = l.ColorName,
ColorCode = l.ColorCode, ColorCode = l.ColorCode,
@@ -916,6 +934,68 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName) .OrderBy(g => g.VendorName)
.ToList(); .ToList();
/// <summary>
/// Like <see cref="MapPowderOrderGroups"/> but collapses coats for the same powder within
/// a vendor group into one line, summing lbs and accumulating coat IDs and job refs.
/// Used for the "Powder in Queue to be Ordered" panel so you order one batch per color.
/// Two coats are considered the same powder when ColorName, ColorCode, Finish, and SKU
/// all match (case- and whitespace-insensitive).
/// </summary>
private static List<PowderOrderVendorGroupDto> MapPowderOrderGroupsMerged(
IEnumerable<DashboardPowderOrderLineData> lines) =>
lines.GroupBy(l => l.VendorId)
.Select(vendorGrp =>
{
var first = vendorGrp.First();
var mergedLines = vendorGrp
.GroupBy(l => (
ColorName: l.ColorName?.Trim().ToLowerInvariant() ?? "",
ColorCode: l.ColorCode?.Trim().ToLowerInvariant() ?? "",
Finish: l.Finish?.Trim().ToLowerInvariant() ?? "",
SKU: l.SKU?.Trim().ToLowerInvariant() ?? ""
))
.Select(powderGrp =>
{
var p = powderGrp.First();
return new PowderOrderLineDto
{
CoatIds = powderGrp.Select(l => l.CoatId).ToList(),
Jobs = powderGrp.Select(l => new PowderOrderJobRefDto
{
JobId = l.JobId,
JobNumber = l.JobNumber,
CustomerName = l.CustomerName,
LbsToOrder = l.LbsToOrder
}).ToList(),
CoatName = p.CoatName,
ColorName = p.ColorName,
ColorCode = p.ColorCode,
Finish = p.Finish,
SKU = p.SKU,
LbsToOrder = powderGrp.Sum(l => l.LbsToOrder),
CostPerLb = p.CostPerLb,
HasInventoryItem = p.HasInventoryItem,
VendorId = p.VendorId
};
})
.OrderBy(l => l.ColorName)
.ThenBy(l => l.CoatName)
.ToList();
return new PowderOrderVendorGroupDto
{
VendorId = vendorGrp.Key,
VendorName = first.VendorName ?? "No Vendor Assigned",
VendorPhone = first.VendorPhone,
VendorEmail = first.VendorEmail,
TotalLbsNeeded = vendorGrp.Sum(l => l.LbsToOrder),
TotalEstCost = vendorGrp.Sum(l => l.CostPerLb.HasValue ? l.LbsToOrder * l.CostPerLb.Value : 0m),
Lines = mergedLines
};
})
.OrderBy(g => g.VendorName)
.ToList();
/// <summary> /// <summary>
/// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/> /// Projects a <see cref="Core.Entities.Job"/> into a lightweight <see cref="DashboardJobDto"/>
/// for use in dashboard job lists. Centralising the mapping in one static helper ensures that /// for use in dashboard job lists. Centralising the mapping in one static helper ensures that
@@ -115,10 +115,11 @@ public class DataExportController : Controller
{ {
switch (sheet) switch (sheet)
{ {
case "Customers": await AddCustomersSheet(package, companyId, headerColor); break; case "Customers": await AddCustomersSheet(package, companyId, headerColor); break;
case "Jobs": await AddJobsSheet(package, companyId, headerColor); break; case "CustomerContacts": await AddCustomerContactsSheet(package, companyId, headerColor); break;
case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break; case "Jobs": await AddJobsSheet(package, companyId, headerColor); break;
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break; case "Quotes": await AddQuotesSheet(package, companyId, headerColor); break;
case "Invoices": await AddInvoicesSheet(package, companyId, headerColor); break;
case "Inventory": await AddInventorySheet(package, companyId, headerColor); break; case "Inventory": await AddInventorySheet(package, companyId, headerColor); break;
case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break; case "Equipment": await AddEquipmentSheet(package, companyId, headerColor); break;
case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break; case "Vendors": await AddVendorsSheet(package, companyId, headerColor); break;
@@ -164,10 +165,11 @@ public class DataExportController : Controller
{ {
switch (sheet) switch (sheet)
{ {
case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break; case "Customers": WriteCsvEntry(zip, "Customers.csv", await BuildCustomersCsv(companyId)); break;
case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break; case "CustomerContacts": WriteCsvEntry(zip, "CustomerContacts.csv", await BuildCustomerContactsCsv(companyId)); break;
case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break; case "Jobs": WriteCsvEntry(zip, "Jobs.csv", await BuildJobsCsv(companyId)); break;
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break; case "Quotes": WriteCsvEntry(zip, "Quotes.csv", await BuildQuotesCsv(companyId)); break;
case "Invoices": WriteCsvEntry(zip, "Invoices.csv", await BuildInvoicesCsv(companyId)); break;
case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break; case "Inventory": WriteCsvEntry(zip, "Inventory.csv", await BuildInventoryCsv(companyId)); break;
case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break; case "Equipment": WriteCsvEntry(zip, "Equipment.csv", await BuildEquipmentCsv(companyId)); break;
case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break; case "Vendors": WriteCsvEntry(zip, "Vendors.csv", await BuildVendorsCsv(companyId)); break;
@@ -240,7 +242,9 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Customers"); var ws = pkg.Workbook.Worksheets.Add("Customers");
var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone", var headers = new[] { "ID", "Company Name", "First Name", "Last Name", "Email", "Phone",
"Commercial", "City", "State", "Active", "Credit Limit", "Commercial", "City", "State", "Active", "Credit Limit",
"Current Balance", "Created At" }; "Current Balance", "Lead Source",
"Ship-To Address", "Ship-To City", "Ship-To State", "Ship-To Zip", "Ship-To Country",
"Created At" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
@@ -259,7 +263,13 @@ public class DataExportController : Controller
ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No"; ws.Cells[r, 10].Value = c.IsActive ? "Yes" : "No";
ws.Cells[r, 11].Value = c.CreditLimit; ws.Cells[r, 11].Value = c.CreditLimit;
ws.Cells[r, 12].Value = c.CurrentBalance; ws.Cells[r, 12].Value = c.CurrentBalance;
ws.Cells[r, 13].Value = c.CreatedAt.ToString("yyyy-MM-dd"); ws.Cells[r, 13].Value = c.LeadSource;
ws.Cells[r, 14].Value = c.ShipToAddress;
ws.Cells[r, 15].Value = c.ShipToCity;
ws.Cells[r, 16].Value = c.ShipToState;
ws.Cells[r, 17].Value = c.ShipToZipCode;
ws.Cells[r, 18].Value = c.ShipToCountry;
ws.Cells[r, 19].Value = c.CreatedAt.ToString("yyyy-MM-dd");
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -282,22 +292,23 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Jobs"); var ws = pkg.Workbook.Worksheets.Add("Jobs");
var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority", var headers = new[] { "ID", "Job Number", "Customer", "Status", "Priority",
"Description", "Due Date", "Final Price", "Created At" }; "Description", "Project Name", "Due Date", "Final Price", "Created At" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
{ {
var r = i + 2; var r = i + 2;
var j = data[i]; var j = data[i];
ws.Cells[r, 1].Value = j.Id; ws.Cells[r, 1].Value = j.Id;
ws.Cells[r, 2].Value = j.JobNumber; ws.Cells[r, 2].Value = j.JobNumber;
ws.Cells[r, 3].Value = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(); ws.Cells[r, 3].Value = j.Customer?.CompanyName ?? $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString(); ws.Cells[r, 4].Value = j.JobStatus?.DisplayName ?? j.JobStatusId.ToString();
ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString(); ws.Cells[r, 5].Value = j.JobPriority?.DisplayName ?? j.JobPriorityId.ToString();
ws.Cells[r, 6].Value = j.Description; ws.Cells[r, 6].Value = j.Description;
ws.Cells[r, 7].Value = j.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 7].Value = j.ProjectName;
ws.Cells[r, 8].Value = j.FinalPrice; ws.Cells[r, 8].Value = j.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 9].Value = j.CreatedAt.ToString("yyyy-MM-dd"); ws.Cells[r, 9].Value = j.FinalPrice;
ws.Cells[r, 10].Value = j.CreatedAt.ToString("yyyy-MM-dd");
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -318,22 +329,23 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Quotes"); var ws = pkg.Workbook.Worksheets.Add("Quotes");
var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status", var headers = new[] { "ID", "Quote Number", "Customer / Prospect", "Status",
"Quote Date", "Expiration Date", "Subtotal", "Tax", "Total" }; "Quote Date", "Expiration Date", "Project Name", "Subtotal", "Tax", "Total" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
{ {
var r = i + 2; var r = i + 2;
var q = data[i]; var q = data[i];
ws.Cells[r, 1].Value = q.Id; ws.Cells[r, 1].Value = q.Id;
ws.Cells[r, 2].Value = q.QuoteNumber; ws.Cells[r, 2].Value = q.QuoteNumber;
ws.Cells[r, 3].Value = string.IsNullOrEmpty(q.ProspectCompanyName) ? $"Customer #{q.CustomerId}" : q.ProspectCompanyName; ws.Cells[r, 3].Value = string.IsNullOrEmpty(q.ProspectCompanyName) ? $"Customer #{q.CustomerId}" : q.ProspectCompanyName;
ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString(); ws.Cells[r, 4].Value = q.QuoteStatus?.DisplayName ?? q.QuoteStatusId.ToString();
ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd"); ws.Cells[r, 5].Value = q.QuoteDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 6].Value = q.ExpirationDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = q.SubTotal; ws.Cells[r, 7].Value = q.ProjectName;
ws.Cells[r, 8].Value = q.TaxAmount; ws.Cells[r, 8].Value = q.SubTotal;
ws.Cells[r, 9].Value = q.Total; ws.Cells[r, 9].Value = q.TaxAmount;
ws.Cells[r, 10].Value = q.Total;
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -456,7 +468,7 @@ public class DataExportController : Controller
var ws = pkg.Workbook.Worksheets.Add("Invoices"); var ws = pkg.Workbook.Worksheets.Add("Invoices");
var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date", var headers = new[] { "ID", "Invoice #", "Customer", "Status", "Invoice Date",
"Due Date", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" }; "Due Date", "Project Name", "Subtotal", "Tax", "Total", "Amount Paid", "Balance Due" };
WriteHeader(ws, headers, hdr); WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++) for (int i = 0; i < data.Count; i++)
@@ -472,11 +484,12 @@ public class DataExportController : Controller
ws.Cells[r, 4].Value = inv.Status.ToString(); ws.Cells[r, 4].Value = inv.Status.ToString();
ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd"); ws.Cells[r, 5].Value = inv.InvoiceDate.ToString("yyyy-MM-dd");
ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd"); ws.Cells[r, 6].Value = inv.DueDate?.ToString("yyyy-MM-dd");
ws.Cells[r, 7].Value = inv.SubTotal; ws.Cells[r, 7].Value = inv.ProjectName;
ws.Cells[r, 8].Value = inv.TaxAmount; ws.Cells[r, 8].Value = inv.SubTotal;
ws.Cells[r, 9].Value = inv.Total; ws.Cells[r, 9].Value = inv.TaxAmount;
ws.Cells[r, 10].Value = inv.AmountPaid; ws.Cells[r, 10].Value = inv.Total;
ws.Cells[r, 11].Value = inv.BalanceDue; ws.Cells[r, 11].Value = inv.AmountPaid;
ws.Cells[r, 12].Value = inv.BalanceDue;
} }
AutoFit(ws, headers.Length); AutoFit(ws, headers.Length);
} }
@@ -530,11 +543,11 @@ public class DataExportController : Controller
.Include(c => c.PricingTier) .Include(c => c.PricingTier)
.Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync(); .Where(c => c.CompanyId == companyId && !c.IsDeleted).OrderBy(c => c.CompanyName).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes"); sb.AppendLine("CompanyName,ContactFirstName,ContactLastName,Email,Phone,MobilePhone,Address,City,State,ZipCode,Country,CustomerType,PricingTierCode,CreditLimit,PaymentTerms,TaxExempt,TaxId,IsActive,Notes,LeadSource,ShipToAddress,ShipToCity,ShipToState,ShipToZipCode,ShipToCountry");
foreach (var c in data) foreach (var c in data)
{ {
var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial"; var customerType = c.IsCommercial ? "Commercial" : "Non-Commercial";
sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)}"); sb.AppendLine($"{CsvEscape(c.CompanyName)},{CsvEscape(c.ContactFirstName)},{CsvEscape(c.ContactLastName)},{CsvEscape(c.Email)},{CsvEscape(c.Phone)},{CsvEscape(c.MobilePhone)},{CsvEscape(c.Address)},{CsvEscape(c.City)},{CsvEscape(c.State)},{CsvEscape(c.ZipCode)},{CsvEscape(c.Country)},{customerType},{CsvEscape(c.PricingTier?.TierName)},{c.CreditLimit},{CsvEscape(c.PaymentTerms)},{c.IsTaxExempt.ToString().ToLower()},{CsvEscape(c.TaxId)},{c.IsActive.ToString().ToLower()},{CsvEscape(c.GeneralNotes)},{CsvEscape(c.LeadSource)},{CsvEscape(c.ShipToAddress)},{CsvEscape(c.ShipToCity)},{CsvEscape(c.ShipToState)},{CsvEscape(c.ShipToZipCode)},{CsvEscape(c.ShipToCountry)}");
} }
return sb.ToString(); return sb.ToString();
} }
@@ -552,13 +565,13 @@ public class DataExportController : Controller
.Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority) .Include(j => j.Customer).Include(j => j.JobStatus).Include(j => j.JobPriority)
.OrderByDescending(j => j.CreatedAt).ToListAsync(); .OrderByDescending(j => j.CreatedAt).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,FinalPrice,CustomerPO,SpecialInstructions,Notes"); sb.AppendLine("JobNumber,CustomerEmail,CustomerName,Status,Priority,ScheduledDate,DueDate,ProjectName,FinalPrice,CustomerPO,SpecialInstructions,Notes");
foreach (var j in data) foreach (var j in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(j.Customer?.CompanyName)
? j.Customer.CompanyName ? j.Customer.CompanyName
: $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(); : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}"); sb.AppendLine($"{CsvEscape(j.JobNumber)},{CsvEscape(j.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(j.JobStatus?.DisplayName)},{CsvEscape(j.JobPriority?.DisplayName)},{j.ScheduledDate?.ToString("yyyy-MM-dd")},{j.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(j.ProjectName)},{j.FinalPrice},{CsvEscape(j.CustomerPO)},{CsvEscape(j.SpecialInstructions)},{CsvEscape(j.InternalNotes)}");
} }
return sb.ToString(); return sb.ToString();
} }
@@ -574,13 +587,13 @@ public class DataExportController : Controller
.Where(q => q.CompanyId == companyId && !q.IsDeleted) .Where(q => q.CompanyId == companyId && !q.IsDeleted)
.Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync(); .Include(q => q.Customer).Include(q => q.QuoteStatus).OrderByDescending(q => q.QuoteDate).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,Subtotal,TaxAmount,Total,Notes,TermsAndConditions"); sb.AppendLine("QuoteNumber,CustomerEmail,CustomerName,ProspectCompany,ProspectContact,ProspectEmail,ProspectPhone,Status,QuoteDate,ExpirationDate,ProjectName,Subtotal,TaxAmount,Total,Notes,TermsAndConditions");
foreach (var q in data) foreach (var q in data)
{ {
var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName) var customerName = !string.IsNullOrWhiteSpace(q.Customer?.CompanyName)
? q.Customer.CompanyName ? q.Customer.CompanyName
: $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim(); : $"{q.Customer?.ContactFirstName} {q.Customer?.ContactLastName}".Trim();
sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}"); sb.AppendLine($"{CsvEscape(q.QuoteNumber)},{CsvEscape(q.Customer?.Email)},{CsvEscape(customerName)},{CsvEscape(q.ProspectCompanyName)},{CsvEscape(q.ProspectContactName)},{CsvEscape(q.ProspectEmail)},{CsvEscape(q.ProspectPhone)},{CsvEscape(q.QuoteStatus?.DisplayName)},{q.QuoteDate:yyyy-MM-dd},{q.ExpirationDate?.ToString("yyyy-MM-dd")},{CsvEscape(q.ProjectName)},{q.SubTotal},{q.TaxAmount},{q.Total},{CsvEscape(q.Notes)},{CsvEscape(q.Terms)}");
} }
return sb.ToString(); return sb.ToString();
} }
@@ -596,17 +609,68 @@ public class DataExportController : Controller
.Where(i => i.CompanyId == companyId && !i.IsDeleted) .Where(i => i.CompanyId == companyId && !i.IsDeleted)
.Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync(); .Include(i => i.Customer).OrderByDescending(i => i.InvoiceDate).ToListAsync();
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Subtotal,Tax,Total,Amount Paid,Balance Due"); sb.AppendLine("ID,Invoice #,Customer,Status,Invoice Date,Due Date,Project Name,Subtotal,Tax,Total,Amount Paid,Balance Due");
foreach (var inv in data) foreach (var inv in data)
{ {
var cust = inv.Customer != null var cust = inv.Customer != null
? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim()) ? (inv.Customer.CompanyName ?? $"{inv.Customer.ContactFirstName} {inv.Customer.ContactLastName}".Trim())
: $"Customer #{inv.CustomerId}"; : $"Customer #{inv.CustomerId}";
sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}"); sb.AppendLine($"{inv.Id},{CsvEscape(inv.InvoiceNumber)},{CsvEscape(cust)},{inv.Status},{inv.InvoiceDate:yyyy-MM-dd},{inv.DueDate?.ToString("yyyy-MM-dd")},{CsvEscape(inv.ProjectName)},{inv.SubTotal},{inv.TaxAmount},{inv.Total},{inv.AmountPaid},{inv.BalanceDue}");
} }
return sb.ToString(); return sb.ToString();
} }
/// <summary>
/// Adds a CustomerContacts worksheet: one row per additional contact linked to the company's customers.
/// CustomerEmail is the join key used by the importer to re-link contacts to their parent customer.
/// </summary>
private async Task AddCustomerContactsSheet(ExcelPackage pkg, int companyId, Color hdr)
{
var data = await _db.CustomerContacts.AsNoTracking().IgnoreQueryFilters()
.Include(cc => cc.Customer)
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
.ToListAsync();
var ws = pkg.Workbook.Worksheets.Add("CustomerContacts");
var headers = new[] { "CustomerEmail", "FirstName", "LastName", "Title", "ContactRole", "Email", "Phone", "MobilePhone", "Notes" };
WriteHeader(ws, headers, hdr);
for (int i = 0; i < data.Count; i++)
{
var r = i + 2;
var cc = data[i];
ws.Cells[r, 1].Value = cc.Customer?.Email;
ws.Cells[r, 2].Value = cc.FirstName;
ws.Cells[r, 3].Value = cc.LastName;
ws.Cells[r, 4].Value = cc.Title;
ws.Cells[r, 5].Value = cc.ContactRole;
ws.Cells[r, 6].Value = cc.Email;
ws.Cells[r, 7].Value = cc.Phone;
ws.Cells[r, 8].Value = cc.MobilePhone;
ws.Cells[r, 9].Value = cc.Notes;
}
AutoFit(ws, headers.Length);
}
/// <summary>
/// Builds the customer contacts CSV string for the specified company.
/// CustomerEmail is the join key used by the importer to re-link contacts to their parent customer.
/// </summary>
private async Task<string> BuildCustomerContactsCsv(int companyId)
{
var data = await _db.CustomerContacts.AsNoTracking().IgnoreQueryFilters()
.Include(cc => cc.Customer)
.Where(cc => cc.CompanyId == companyId && !cc.IsDeleted)
.OrderBy(cc => cc.Customer!.CompanyName).ThenBy(cc => cc.LastName).ThenBy(cc => cc.FirstName)
.ToListAsync();
var sb = new StringBuilder();
sb.AppendLine("CustomerEmail,FirstName,LastName,Title,ContactRole,Email,Phone,MobilePhone,Notes");
foreach (var cc in data)
sb.AppendLine($"{CsvEscape(cc.Customer?.Email)},{CsvEscape(cc.FirstName)},{CsvEscape(cc.LastName)},{CsvEscape(cc.Title)},{CsvEscape(cc.ContactRole)},{CsvEscape(cc.Email)},{CsvEscape(cc.Phone)},{CsvEscape(cc.MobilePhone)},{CsvEscape(cc.Notes)}");
return sb.ToString();
}
/// <summary> /// <summary>
/// Builds the inventory CSV string for the specified company, ordered alphabetically by name. /// Builds the inventory CSV string for the specified company, ordered alphabetically by name.
/// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported. /// Column names match <see cref="InventoryItemImportDto"/> exactly so the file can be re-imported.
@@ -0,0 +1,96 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
/// <summary>
/// Allows company admins logged into the DEMO company to reset demo data without
/// switching to a SuperAdmin account. The CompanyCode guard ensures this action
/// cannot affect any real tenant even if someone crafts a direct POST.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class DemoController : Controller
{
private readonly ISeedDataService _seedDataService;
private readonly ITenantContext _tenantContext;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DemoController> _logger;
public DemoController(
ISeedDataService seedDataService,
ITenantContext tenantContext,
IUnitOfWork unitOfWork,
ILogger<DemoController> logger)
{
_seedDataService = seedDataService;
_tenantContext = tenantContext;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Resets the demo company's seed data and redirects back to the dashboard.
/// Fails fast if the current company is not the DEMO company.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetDemoData()
{
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId == null)
{
TempData["ErrorMessage"] = "Unable to determine current company.";
return RedirectToAction("Index", "CompanySettings");
}
// Safety gate: only the DEMO company can use this action
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
if (company == null || company.CompanyCode != "DEMO")
{
TempData["ErrorMessage"] = "Demo reset is only available for the DEMO company.";
return RedirectToAction("Index", "CompanySettings");
}
try
{
var removeOptions = new RemoveSeedDataOptions
{
Customers = true,
InventoryItems = true,
Equipment = true,
Catalog = true,
Bills = true,
Expenses = true,
Workers = false,
Vendors = true,
NamedOvens = true,
Appointments = true,
ForceRemoveAll = true,
};
var removeResult = await _seedDataService.RemoveSeedDataAsync(companyId.Value, removeOptions);
if (!removeResult.Success)
{
TempData["ErrorMessage"] = $"Demo reset failed during wipe: {removeResult.Message}";
return RedirectToAction("Index", "CompanySettings");
}
var seedResult = await _seedDataService.SeedCompanyDataAsync(companyId.Value);
TempData["SuccessMessage"] = $"Demo data reset complete &mdash; {seedResult.ItemsSeeded} records refreshed with today&rsquo;s dates.";
if (seedResult.Warnings.Any())
TempData["WarningMessage"] = $"{seedResult.Warnings.Count} item(s) skipped during reseed (check Platform &rsaquo; Seed Data for details).";
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resetting demo data for company {CompanyId}", companyId);
TempData["ErrorMessage"] = $"Demo reset encountered an error: {ex.Message}";
}
return RedirectToAction("Index", "CompanySettings");
}
}
@@ -18,92 +18,88 @@ public class InAppNotificationsController : Controller
} }
/// <summary> /// <summary>
/// Displays the paginated notification history page (read and unread). SuperAdmins see only platform-level notifications (CompanyId 0) via IgnoreQueryFilters; regular users rely on the global query filter for tenant isolation. /// Displays the paginated notification history page (read and unread). SuperAdmins see only
/// platform-level notifications (CompanyId 0); regular users rely on the global query filter.
/// ORDER BY / SKIP / TAKE run in SQL via GetPagedAsync — no full-table load.
/// </summary> /// </summary>
public async Task<IActionResult> Index(int pageNumber = 1, int pageSize = 25) public async Task<IActionResult> Index(int pageNumber = 1, int pageSize = 25)
{ {
pageNumber = Math.Max(1, pageNumber); pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25; pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
var all = _tenant.IsPlatformAdmin() var isPlatformAdmin = _tenant.IsPlatformAdmin();
? (await _unitOfWork.InAppNotifications.FindAsync( var (items, totalCount) = await _unitOfWork.InAppNotifications
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList() .GetPagedAsync(isPlatformAdmin, pageNumber, pageSize);
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
var totalCount = all.Count;
var tz = ViewBag.CompanyTimeZone as string; var tz = ViewBag.CompanyTimeZone as string;
var items = all var vm = items.Select(n => new
.OrderByDescending(n => n.CreatedAt) {
.Skip((pageNumber - 1) * pageSize) n.Id,
.Take(pageSize) n.Title,
.Select(n => new n.Message,
{ n.Link,
n.Id, n.NotificationType,
n.Title, n.IsRead,
n.Message, n.ReadAt,
n.Link, CreatedAt = n.CreatedAt
n.NotificationType, }).ToList();
n.IsRead,
n.ReadAt,
CreatedAt = n.CreatedAt
})
.ToList();
ViewBag.TotalCount = totalCount; ViewBag.TotalCount = totalCount;
ViewBag.PageNumber = pageNumber; ViewBag.PageNumber = pageNumber;
ViewBag.PageSize = pageSize; ViewBag.PageSize = pageSize;
ViewBag.TotalPages = (int)Math.Ceiling((double)totalCount / pageSize); ViewBag.TotalPages = (int)Math.Ceiling((double)totalCount / pageSize);
return View(items); return View(vm);
} }
/// <summary> /// <summary>
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. /// AJAX endpoint for the bell dropdown — returns the 20 most recent notifications.
/// ORDER BY and TAKE run in SQL; no full-table load.
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> Recent() public async Task<IActionResult> Recent()
{ {
var all = _tenant.IsPlatformAdmin() var isPlatformAdmin = _tenant.IsPlatformAdmin();
? (await _unitOfWork.InAppNotifications.FindAsync( var items = await _unitOfWork.InAppNotifications.GetRecentAsync(isPlatformAdmin, take: 20);
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
var tz = ViewBag.CompanyTimeZone as string; var tz = ViewBag.CompanyTimeZone as string;
var items = all
.OrderByDescending(n => n.CreatedAt)
.Take(20)
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt })
.ToList();
var unreadCount = items.Count(n => !n.IsRead); var unreadCount = items.Count(n => !n.IsRead);
return Json(new { count = unreadCount, items = items.Select(n => new { return Json(new
n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, {
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt") count = unreadCount,
}) }); items = items.Select(n => new
{
n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead,
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
})
});
} }
/// <summary> /// <summary>
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. /// AJAX endpoint for the bell badge — returns unread count plus up to 20 unread items.
/// COUNT and ORDER BY / TAKE run in SQL; no full-table load.
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> Unread() public async Task<IActionResult> Unread()
{ {
var items = _tenant.IsPlatformAdmin() var isPlatformAdmin = _tenant.IsPlatformAdmin();
? (await _unitOfWork.InAppNotifications.FindAsync( var (items, unreadCount) = await _unitOfWork.InAppNotifications
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)) .GetUnreadAsync(isPlatformAdmin, take: 20);
.OrderByDescending(n => n.CreatedAt).Take(20).ToList()
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead))
.OrderByDescending(n => n.CreatedAt).Take(20).ToList();
var tz = ViewBag.CompanyTimeZone as string; var tz = ViewBag.CompanyTimeZone as string;
return Json(new { count = items.Count, items = items.Select(n => new { return Json(new
n.Id, n.Title, n.Message, n.Link, n.NotificationType, {
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt") count = unreadCount,
}) }); items = items.Select(n => new
{
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
})
});
} }
/// <summary> /// <summary>
/// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter. /// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins
/// (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
public async Task<IActionResult> MarkRead(int id) public async Task<IActionResult> MarkRead(int id)
@@ -1642,8 +1642,10 @@ public class InventoryController : Controller
var userId = _userManager.GetUserId(User); var userId = _userManager.GetUserId(User);
var recentCutoff = DateTime.UtcNow.AddDays(-7);
var myJobs = (await _unitOfWork.Jobs.FindAsync( var myJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId, j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && j.AssignedUserId == userId,
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus)) j => j.JobStatus))
@@ -1651,7 +1653,7 @@ public class InventoryController : Controller
.Select(j => new ScanJobOption .Select(j => new ScanJobOption
{ {
Id = j.Id, Id = j.Id,
JobNumber = j.JobNumber, JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
CustomerName = j.Customer != null CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer" : "No Customer"
@@ -1660,7 +1662,7 @@ public class InventoryController : Controller
var myJobIds = myJobs.Select(j => j.Id).ToHashSet(); var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
var otherJobs = (await _unitOfWork.Jobs.FindAsync( var otherJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id), j => (!j.JobStatus.IsTerminalStatus || j.UpdatedAt >= recentCutoff) && !myJobIds.Contains(j.Id),
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus)) j => j.JobStatus))
@@ -1669,7 +1671,7 @@ public class InventoryController : Controller
.Select(j => new ScanJobOption .Select(j => new ScanJobOption
{ {
Id = j.Id, Id = j.Id,
JobNumber = j.JobNumber, JobNumber = j.JobNumber + (j.JobStatus.IsTerminalStatus ? " (completed)" : ""),
CustomerName = j.Customer != null CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer" : "No Customer"
@@ -1686,9 +1688,64 @@ public class InventoryController : Controller
} }
/// <summary> /// <summary>
/// Records powder usage logged via the mobile scan page. Creates a JobUsage /// Core inventory usage recording logic shared by LogUsage (scan page) and LogMaterial (modal).
/// InventoryTransaction (and PowderUsageLog) when a job is selected, or an /// Deducts quantityUsed from QuantityOnHand, writes an InventoryTransaction, and posts GL entries.
/// Adjustment transaction when logging without a job. Updates QuantityOnHand. /// </summary>
private async Task<InventoryUsageResult> RecordInventoryUsageAsync(
int inventoryItemId, int? jobId, decimal quantityUsed,
InventoryTransactionType transactionType, string? notes)
{
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null)
return new InventoryUsageResult(false, "Inventory item not found.", 0, "", "");
string? reference = null;
if (jobId.HasValue)
{
var job = await _unitOfWork.Jobs.GetByIdAsync(jobId.Value);
reference = job != null ? $"Job {job.JobNumber}" : null;
}
item.QuantityOnHand -= quantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = transactionType,
Quantity = -quantityUsed,
UnitCost = item.UnitCost,
TotalCost = quantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
Reference = reference,
Notes = notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return new InventoryUsageResult(
true,
$"Logged {quantityUsed:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.",
item.QuantityOnHand,
item.UnitOfMeasure,
item.Name);
}
/// <summary>
/// Records powder usage from the mobile scan page. Resolves the used quantity
/// (caller already converts "remaining weight" to delta before posting) and redirects to ScanSuccess.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
@@ -1697,55 +1754,26 @@ public class InventoryController : Controller
{ {
try try
{ {
var item = await _unitOfWork.InventoryItems.GetByIdAsync(inventoryItemId);
if (item == null) return NotFound();
if (quantity <= 0) if (quantity <= 0)
{ {
TempData["ScanError"] = "Quantity must be greater than zero."; TempData["ScanError"] = "Quantity must be greater than zero.";
return RedirectToAction(nameof(Scan), new { id = inventoryItemId }); return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
} }
var userId = _userManager.GetUserId(User) ?? string.Empty; var result = await RecordInventoryUsageAsync(
// Scan-based logging always records as JobUsage; Adjustment is for manual stock corrections only inventoryItemId, jobId, quantity,
var txnType = InventoryTransactionType.JobUsage; InventoryTransactionType.JobUsage, notes);
item.QuantityOnHand -= quantity; if (!result.Success)
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new InventoryTransaction
{ {
InventoryItemId = item.Id, TempData["ScanError"] = result.Message;
TransactionType = txnType, return RedirectToAction(nameof(Scan), new { id = inventoryItemId });
Quantity = -quantity,
UnitCost = item.UnitCost,
TotalCost = quantity * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = jobId,
Reference = jobId.HasValue ? $"Job #{jobId}" : null,
Notes = notes?.Trim()
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.SaveChangesAsync();
// GL: DR COGS, CR Inventory Asset — no-op if accounts not configured on the item
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = quantity * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
} }
// PowderUsageLog requires a specific JobItem + Coat FK — scan-based logging TempData["ScanSuccess"] = result.Message;
// doesn't have that context, so we rely on the InventoryTransaction alone
// for the audit trail. Coat-level PowderUsageLogs are created by the job workflow.
TempData["ScanSuccess"] = $"Logged {quantity:N2} {item.UnitOfMeasure} of {item.Name}. New balance: {item.QuantityOnHand:N2} {item.UnitOfMeasure}.";
TempData["ScanItemId"] = inventoryItemId.ToString(); TempData["ScanItemId"] = inventoryItemId.ToString();
TempData["ScanJobId"] = jobId?.ToString(); TempData["ScanJobId"] = jobId?.ToString();
TempData["ScanItemName"] = item.Name; TempData["ScanItemName"] = result.ItemName;
return RedirectToAction(nameof(ScanSuccess)); return RedirectToAction(nameof(ScanSuccess));
} }
catch (Exception ex) catch (Exception ex)
@@ -1756,6 +1784,43 @@ public class InventoryController : Controller
} }
} }
/// <summary>
/// Records manual material usage from the job details modal. Accepts JSON, resolves
/// the amount used (caller sends the already-computed used quantity), and returns JSON
/// so the modal can close and refresh inline.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
{
try
{
if (req.QuantityUsed <= 0)
return Json(new { success = false, message = "Quantity used must be greater than zero." });
var txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
var result = await RecordInventoryUsageAsync(
req.InventoryItemId, req.JobId, req.QuantityUsed, txnType, req.Notes);
return Json(new
{
success = result.Success,
message = result.Message,
newBalance = result.NewBalance,
unitOfMeasure = result.UnitOfMeasure,
itemName = result.ItemName
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
/// <summary> /// <summary>
/// Success screen shown after a usage log is saved. Offers "Log Another Item for /// Success screen shown after a usage log is saved. Offers "Log Another Item for
/// This Job" and "Done" options. /// This Job" and "Done" options.
@@ -2003,7 +2068,7 @@ public class InventoryController : Controller
/// <summary> /// <summary>
/// Returns the current values of a JobUsage InventoryTransaction plus a list of active /// Returns the current values of a JobUsage InventoryTransaction plus a list of active
/// jobs so the edit modal can be pre-populated without a full page reload. /// jobs (plus the currently assigned job even if terminal) for the edit modal.
/// </summary> /// </summary>
[HttpGet] [HttpGet]
public async Task<IActionResult> GetUsageForEdit(int id) public async Task<IActionResult> GetUsageForEdit(int id)
@@ -2034,10 +2099,27 @@ public class InventoryController : Controller
}) })
.ToList(); .ToList();
// If the assigned job has terminal status it won't appear in the active list; insert it at the top
// so the dropdown pre-selects correctly and the user can see the existing job assignment.
if (txn.JobId.HasValue && jobs.All(j => j.Id != txn.JobId.Value))
{
var assignedJob = await _unitOfWork.Jobs.GetByIdAsync(txn.JobId.Value, false, j => j.Customer);
if (assignedJob != null)
jobs.Insert(0, new ScanJobOption
{
Id = assignedJob.Id,
JobNumber = assignedJob.JobNumber,
CustomerName = assignedJob.Customer != null
? (assignedJob.Customer.CompanyName ?? $"{assignedJob.Customer.ContactFirstName} {assignedJob.Customer.ContactLastName}".Trim())
: "No Customer"
});
}
return Json(new return Json(new
{ {
transactionId = txn.Id, transactionId = txn.Id,
jobId = txn.JobId, jobId = txn.JobId,
quantity = Math.Abs(txn.Quantity),
notes = txn.Notes, notes = txn.Notes,
transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"), transactionDate = txn.TransactionDate.ToString("yyyy-MM-ddTHH:mm"),
itemName = txn.InventoryItem?.Name, itemName = txn.InventoryItem?.Name,
@@ -2046,14 +2128,15 @@ public class InventoryController : Controller
} }
/// <summary> /// <summary>
/// Saves edits to a JobUsage InventoryTransaction's job assignment, notes, and date. /// Saves edits to a JobUsage InventoryTransaction: job assignment, quantity, notes, and date.
/// Quantity and balance are not changed. /// When quantity changes the InventoryItem.QuantityOnHand is adjusted by the delta so the
/// ledger balance remains consistent.
/// </summary> /// </summary>
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate) public async Task<IActionResult> EditUsageTransaction(int id, int? jobId, string? notes, DateTime transactionDate, decimal? quantity)
{ {
var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id); var txn = await _unitOfWork.InventoryTransactions.GetByIdAsync(id, false, t => t.InventoryItem);
if (txn == null) return NotFound(); if (txn == null) return NotFound();
if (txn.TransactionType != InventoryTransactionType.JobUsage if (txn.TransactionType != InventoryTransactionType.JobUsage
&& txn.TransactionType != InventoryTransactionType.Adjustment) && txn.TransactionType != InventoryTransactionType.Adjustment)
@@ -2075,6 +2158,28 @@ public class InventoryController : Controller
if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment) if (jobId.HasValue && txn.TransactionType == InventoryTransactionType.Adjustment)
txn.TransactionType = InventoryTransactionType.JobUsage; txn.TransactionType = InventoryTransactionType.JobUsage;
// Adjust inventory when the logged quantity is changed.
// txn.Quantity is stored as a negative number for usage (e.g. -3.5 for 3.5 lbs used).
if (quantity.HasValue && quantity.Value > 0)
{
var oldUsed = Math.Abs(txn.Quantity);
var newUsed = quantity.Value;
if (oldUsed != newUsed)
{
var item = txn.InventoryItem ?? await _unitOfWork.InventoryItems.GetByIdAsync(txn.InventoryItemId);
if (item != null)
{
// Positive delta means less was actually used → restore the difference to inventory.
item.QuantityOnHand += oldUsed - newUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
txn.BalanceAfter = item.QuantityOnHand;
}
txn.Quantity = -newUsed;
txn.TotalCost = newUsed * txn.UnitCost;
}
}
txn.Notes = notes?.Trim(); txn.Notes = notes?.Trim();
txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc txn.TransactionDate = transactionDate.Kind == DateTimeKind.Utc
? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc); ? transactionDate : DateTime.SpecifyKind(transactionDate, DateTimeKind.Utc);
@@ -2094,3 +2199,21 @@ public class ScanJobOption
public string JobNumber { get; set; } = string.Empty; public string JobNumber { get; set; } = string.Empty;
public string CustomerName { get; set; } = string.Empty; public string CustomerName { get; set; } = string.Empty;
} }
/// <summary>Result returned by RecordInventoryUsageAsync.</summary>
public record InventoryUsageResult(
bool Success,
string Message,
decimal NewBalance,
string UnitOfMeasure,
string ItemName);
/// <summary>JSON body for the LogMaterial endpoint (job details modal).</summary>
public class LogMaterialRequest
{
public int JobId { get; set; }
public int InventoryItemId { get; set; }
public decimal QuantityUsed { get; set; }
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
@@ -372,6 +372,7 @@ public class InvoicesController : Controller
dto.JobId = job.Id; dto.JobId = job.Id;
dto.CustomerId = job.CustomerId; dto.CustomerId = job.CustomerId;
dto.CustomerPO = job.CustomerPO; dto.CustomerPO = job.CustomerPO;
dto.ProjectName = job.ProjectName;
// Resolve catalog item revenue accounts for pre-population // Resolve catalog item revenue accounts for pre-population
var catalogItemIds = job.JobItems var catalogItemIds = job.JobItems
@@ -710,6 +711,7 @@ public class InvoicesController : Controller
InternalNotes = dto.InternalNotes, InternalNotes = dto.InternalNotes,
Terms = dto.Terms, Terms = dto.Terms,
CustomerPO = dto.CustomerPO, CustomerPO = dto.CustomerPO,
ProjectName = dto.ProjectName,
CompanyId = currentUser.CompanyId, CompanyId = currentUser.CompanyId,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CreatedBy = currentUser.Email CreatedBy = currentUser.Email
@@ -901,6 +903,7 @@ public class InvoicesController : Controller
InternalNotes = invoice.InternalNotes, InternalNotes = invoice.InternalNotes,
Terms = invoice.Terms, Terms = invoice.Terms,
CustomerPO = invoice.CustomerPO, CustomerPO = invoice.CustomerPO,
ProjectName = invoice.ProjectName ?? invoice.Job?.ProjectName,
InvoiceItems = invoice.InvoiceItems InvoiceItems = invoice.InvoiceItems
.Where(i => !i.IsDeleted) .Where(i => !i.IsDeleted)
.OrderBy(i => i.DisplayOrder) .OrderBy(i => i.DisplayOrder)
@@ -1036,6 +1039,7 @@ public class InvoicesController : Controller
invoice.InternalNotes = dto.InternalNotes; invoice.InternalNotes = dto.InternalNotes;
invoice.Terms = dto.Terms; invoice.Terms = dto.Terms;
invoice.CustomerPO = dto.CustomerPO; invoice.CustomerPO = dto.CustomerPO;
invoice.ProjectName = dto.ProjectName;
invoice.UpdatedAt = DateTime.UtcNow; invoice.UpdatedAt = DateTime.UtcNow;
invoice.UpdatedBy = currentUser?.Email; invoice.UpdatedBy = currentUser?.Email;
@@ -1099,7 +1103,7 @@ public class InvoicesController : Controller
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}"; paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}"; var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl); await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
this.SetNotificationResultToast(notifLog); this.SetNotificationResultToast(notifLog);
} }
catch (Exception notifyEx) catch (Exception notifyEx)
@@ -1186,7 +1190,7 @@ public class InvoicesController : Controller
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none"); id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
} }
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
this.SetNotificationResultToast(notifLog); this.SetNotificationResultToast(notifLog);
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent."; TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
@@ -1317,7 +1321,7 @@ public class InvoicesController : Controller
} }
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
this.SetNotificationResultToast(paymentNotifLog); this.SetNotificationResultToast(paymentNotifLog);
TempData["Success"] = overpayment > 0 TempData["Success"] = overpayment > 0
@@ -1884,7 +1888,7 @@ public class InvoicesController : Controller
/// Details view can show an inline toast with the delivery outcome. /// Details view can show an inline toast with the delivery outcome.
/// </summary> /// </summary>
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null) public async Task<IActionResult> ResendInvoice(int id, string? overrideEmail = null, bool sendEmail = true, bool sendSms = false)
{ {
try try
{ {
@@ -1898,7 +1902,9 @@ public class InvoicesController : Controller
if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff) if (invoice.Status == InvoiceStatus.Voided || invoice.Status == InvoiceStatus.WrittenOff)
return Json(new { success = false, message = "Voided invoices cannot be resent." }); return Json(new { success = false, message = "Voided invoices cannot be resent." });
// Validate override email when provided if (!sendEmail && !sendSms)
return Json(new { success = false, message = "Select at least one delivery channel (email or SMS)." });
overrideEmail = overrideEmail?.Trim(); overrideEmail = overrideEmail?.Trim();
if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@')) if (!string.IsNullOrWhiteSpace(overrideEmail) && !overrideEmail.Contains('@'))
return Json(new { success = false, message = "The email address provided is not valid." }); return Json(new { success = false, message = "The email address provided is not valid." });
@@ -1911,32 +1917,55 @@ public class InvoicesController : Controller
? overrideEmail ? overrideEmail
: invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty; : invoice.Customer?.BillingEmail ?? invoice.Customer?.Email ?? string.Empty;
if (string.IsNullOrWhiteSpace(recipientEmail)) if (sendEmail && string.IsNullOrWhiteSpace(recipientEmail))
return Json(new { success = false, message = "No email address on file. Please provide an address to send to." }); return Json(new { success = false, message = "No email address on file. Please provide an address to send to." });
// Ensure a permanent view token exists so the SMS link always works.
string? viewUrl = null;
if (sendSms)
{
if (string.IsNullOrEmpty(invoice.PublicViewToken))
{
invoice.PublicViewToken = Guid.NewGuid().ToString("N");
await _unitOfWork.Invoices.UpdateAsync(invoice);
await _unitOfWork.CompleteAsync();
}
viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
}
byte[]? pdfBytes = null; byte[]? pdfBytes = null;
string? pdfFilename = null; string? pdfFilename = null;
try if (sendEmail)
{ {
pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId); try
pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf"; {
} pdfBytes = await BuildInvoicePdfAsync(invoice, currentUser!.CompanyId);
catch (Exception pdfEx) pdfFilename = $"Invoice-{invoice.InvoiceNumber}.pdf";
{ }
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id); catch (Exception pdfEx)
{
_logger.LogWarning(pdfEx, "PDF generation failed during resend of invoice {Id}; sending without attachment", id);
}
} }
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, pdfFilename, overrideEmail: overrideEmail); await _notificationService.NotifyInvoiceSentAsync(
invoice, pdfBytes, pdfFilename,
overrideEmail: overrideEmail,
sendSms: sendSms,
viewUrl: viewUrl);
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id); var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
if (latestLog?.Status == NotificationStatus.Failed) if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });
if (latestLog?.Status == NotificationStatus.Skipped) if (latestLog?.Status == NotificationStatus.Skipped && !sendSms)
return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." }); return Json(new { success = false, message = $"{recipientName} has email notifications disabled or no email address on file." });
return Json(new { success = true, message = $"Invoice sent to {recipientEmail}." }); var channels = new List<string>();
if (sendEmail && !string.IsNullOrWhiteSpace(recipientEmail)) channels.Add($"email ({recipientEmail})");
if (sendSms) channels.Add("SMS");
return Json(new { success = true, message = $"Invoice re-sent via {string.Join(" and ", channels)}." });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -1958,7 +1987,7 @@ public class InvoicesController : Controller
public async Task<IActionResult> NotificationsSent(int id) public async Task<IActionResult> NotificationsSent(int id)
{ {
var tz = ViewBag.CompanyTimeZone as string; var tz = ViewBag.CompanyTimeZone as string;
var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id); var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), var logs = entries.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message, Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.Message,
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
@@ -2078,7 +2107,7 @@ public class InvoicesController : Controller
/// eight-table include chain. Returns null if not found or soft-deleted. /// eight-table include chain. Returns null if not found or soft-deleted.
/// </summary> /// </summary>
private async Task<Invoice?> LoadInvoiceForViewAsync(int id) => private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
await _unitOfWork.Invoices.LoadForViewAsync(id); await _unitOfWork.Invoices.LoadForViewAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
/// <summary> /// <summary>
/// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper /// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper
@@ -130,7 +130,7 @@ public class JobTemplatesController : Controller
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId); var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId, companyId);
if (job == null) return NotFound(); if (job == null) return NotFound();
@@ -152,6 +152,10 @@ public class JobsController : Controller
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered; || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered;
} }
else if (statusGroup == "ready")
{
filter = j => j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup;
}
// "all" or unknown group: no filter applied (show every status) // "all" or unknown group: no filter applied (show every status)
} }
else if (!string.IsNullOrWhiteSpace(searchTerm)) else if (!string.IsNullOrWhiteSpace(searchTerm))
@@ -329,7 +333,7 @@ public class JobsController : Controller
[HttpPost, ValidateAntiForgeryToken] [HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> MoveCard([FromBody] MoveCardRequest req) public async Task<IActionResult> MoveCard([FromBody] MoveCardRequest req)
{ {
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId); var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) if (job == null)
return Json(new { success = false, message = "Job not found." }); return Json(new { success = false, message = "Job not found." });
@@ -392,7 +396,8 @@ public class JobsController : Controller
try try
{ {
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value, companyId);
if (job == null) if (job == null)
{ {
return NotFound(); return NotFound();
@@ -412,7 +417,7 @@ public class JobsController : Controller
var jobDto = _mapper.Map<JobDto>(job); var jobDto = _mapper.Map<JobDto>(job);
// Load change history // Load change history
var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value); var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value, companyId);
_logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value); _logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value);
var changeHistoryDtos = _mapper.Map<List<JobChangeHistoryDto>>(changeHistories); var changeHistoryDtos = _mapper.Map<List<JobChangeHistoryDto>>(changeHistories);
@@ -737,7 +742,8 @@ public class JobsController : Controller
try try
{ {
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value); var companyId = _tenantContext.GetCurrentCompanyId();
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value, companyId ?? 0);
if (job == null) if (job == null)
{ {
return NotFound(); return NotFound();
@@ -747,7 +753,6 @@ public class JobsController : Controller
var jobDto = _mapper.Map<JobDto>(job); var jobDto = _mapper.Map<JobDto>(job);
// Get company info for the header // Get company info for the header
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue) if (companyId.HasValue)
{ {
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value); var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
@@ -1264,7 +1269,7 @@ public class JobsController : Controller
try try
{ {
var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value); var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) if (job == null)
{ {
return NotFound(); return NotFound();
@@ -1778,7 +1783,7 @@ public class JobsController : Controller
_logger.LogWarning(ex, "Notification failed for job {Id}", job.Id); _logger.LogWarning(ex, "Notification failed for job {Id}", job.Id);
} }
var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id); var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id, job.CompanyId);
this.SetNotificationResultToast(editNotifLog); this.SetNotificationResultToast(editNotifLog);
} }
@@ -1981,6 +1986,145 @@ public class JobsController : Controller
} }
/// <summary> /// <summary>
/// <summary>
/// Creates a new job that is a copy of an existing job. All items, coats, and prep services
/// are deep-copied. Pricing-routing flags (IsAiItem, IsGenericItem, IsLaborItem, IsSalesItem)
/// are preserved so pricing behaves identically. Dates, worker assignment, and invoice links
/// are cleared; status resets to Pending so the job enters the normal workflow from the start.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public async Task<IActionResult> CloneJob(int id)
{
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id, companyId);
if (source == null) return NotFound();
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
if (pendingStatus == null)
{
this.ToastError("Could not find Pending status for this company.");
return RedirectToAction(nameof(Details), new { id });
}
var newJob = new Job
{
JobNumber = await GenerateJobNumber(),
CustomerId = source.CustomerId,
CompanyId = companyId,
JobStatusId = pendingStatus.Id,
JobPriorityId = source.JobPriorityId,
Description = source.Description,
CustomerPO = source.CustomerPO,
ProjectName = source.ProjectName,
SpecialInstructions = source.SpecialInstructions,
InternalNotes = source.InternalNotes,
Tags = source.Tags,
IsRushJob = source.IsRushJob,
RequiresCustomerApproval = source.RequiresCustomerApproval,
DiscountType = source.DiscountType,
DiscountValue = source.DiscountValue,
DiscountReason = source.DiscountReason,
OvenCostId = source.OvenCostId,
OvenBatches = source.OvenBatches,
OvenCycleMinutes = source.OvenCycleMinutes,
ShopSuppliesPercent = source.ShopSuppliesPercent,
ShopAccessCode = Guid.NewGuid()
};
await _unitOfWork.Jobs.AddAsync(newJob);
await _unitOfWork.CompleteAsync();
foreach (var srcItem in source.JobItems.Where(i => !i.IsDeleted))
{
var newItem = new JobItem
{
JobId = newJob.Id,
CompanyId = companyId,
Description = srcItem.Description,
Quantity = srcItem.Quantity,
ColorName = srcItem.ColorName,
ColorCode = srcItem.ColorCode,
Finish = srcItem.Finish,
SurfaceArea = srcItem.SurfaceArea,
SurfaceAreaSqFt = srcItem.SurfaceAreaSqFt,
CatalogItemId = srcItem.CatalogItemId,
UnitPrice = srcItem.UnitPrice,
TotalPrice = srcItem.TotalPrice,
LaborCost = srcItem.LaborCost,
IsGenericItem = srcItem.IsGenericItem,
ManualUnitPrice = srcItem.ManualUnitPrice,
PowderCostOverride = srcItem.PowderCostOverride,
IsLaborItem = srcItem.IsLaborItem,
IsSalesItem = srcItem.IsSalesItem,
IsAiItem = srcItem.IsAiItem,
AiTags = srcItem.AiTags,
IsCustomFormulaItem = srcItem.IsCustomFormulaItem,
CustomItemTemplateId = srcItem.CustomItemTemplateId,
FormulaFieldValuesJson = srcItem.FormulaFieldValuesJson,
Sku = srcItem.Sku,
IncludePrepCost = srcItem.IncludePrepCost,
RequiresSandblasting = srcItem.RequiresSandblasting,
RequiresMasking = srcItem.RequiresMasking,
EstimatedMinutes = srcItem.EstimatedMinutes,
Complexity = srcItem.Complexity,
Notes = srcItem.Notes
// AiPredictionId intentionally not copied — prediction belongs to original quote
};
await _unitOfWork.JobItems.AddAsync(newItem);
await _unitOfWork.CompleteAsync();
foreach (var srcCoat in srcItem.Coats.Where(c => !c.IsDeleted))
{
await _unitOfWork.JobItemCoats.AddAsync(new JobItemCoat
{
JobItemId = newItem.Id,
CompanyId = companyId,
CoatName = srcCoat.CoatName,
Sequence = srcCoat.Sequence,
InventoryItemId = srcCoat.InventoryItemId,
ColorName = srcCoat.ColorName,
VendorId = srcCoat.VendorId,
ColorCode = srcCoat.ColorCode,
Finish = srcCoat.Finish,
CoverageSqFtPerLb = srcCoat.CoverageSqFtPerLb,
TransferEfficiency = srcCoat.TransferEfficiency,
PowderCostPerLb = srcCoat.PowderCostPerLb,
PowderToOrder = srcCoat.PowderToOrder,
NoExtraLayerCharge = srcCoat.NoExtraLayerCharge,
Notes = srcCoat.Notes
// Powder ordering / receiving tracking fields intentionally not copied
});
}
foreach (var srcPrep in srcItem.PrepServices.Where(p => !p.IsDeleted))
{
await _unitOfWork.JobItemPrepServices.AddAsync(new JobItemPrepService
{
JobItemId = newItem.Id,
CompanyId = companyId,
PrepServiceId = srcPrep.PrepServiceId,
EstimatedMinutes = srcPrep.EstimatedMinutes,
BlastSetupId = srcPrep.BlastSetupId
});
}
}
await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Job cloned as {newJob.JobNumber} &mdash; review and update dates before scheduling.");
return RedirectToAction(nameof(Details), new { id = newJob.Id });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error cloning job {JobId}", id);
this.ToastError("An error occurred while cloning the job.");
return RedirectToAction(nameof(Details), new { id });
}
}
/// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001). /// Generates the next sequential job number in the format PREFIX-YYMM-#### (e.g. JOB-2404-0001).
/// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter — /// Uses IgnoreQueryFilters so soft-deleted jobs are included in the sequence counter —
/// this prevents number reuse if a job is deleted after being created this month. /// this prevents number reuse if a job is deleted after being created this month.
@@ -2266,7 +2410,7 @@ public class JobsController : Controller
{ {
try try
{ {
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId); var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return Json(new { success = false, message = "Job not found" }); if (job == null) return Json(new { success = false, message = "Job not found" });
var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId); var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId);
@@ -2517,7 +2661,7 @@ public class JobsController : Controller
_logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId); _logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId);
} }
var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId); var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId, job.CompanyId);
this.SetNotificationResultToast(statusNotifLog); this.SetNotificationResultToast(statusNotifLog);
} }
@@ -2744,7 +2888,7 @@ public class JobsController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> CompleteJobModal(int id) public async Task<IActionResult> CompleteJobModal(int id)
{ {
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id); var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return NotFound(); if (job == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User); var currentUser = await _userManager.GetUserAsync(User);
if (currentUser != null) if (currentUser != null)
@@ -2928,7 +3072,7 @@ public class JobsController : Controller
_logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId); _logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId);
} }
var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId); var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
this.SetNotificationResultToast(completeNotifLog); this.SetNotificationResultToast(completeNotifLog);
} }
@@ -3735,7 +3879,7 @@ public class JobsController : Controller
[HttpPost] [HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto) public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
{ {
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId); var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return NotFound(); if (job == null) return NotFound();
var companyId = job.CompanyId; var companyId = job.CompanyId;
@@ -3813,7 +3957,7 @@ public class JobsController : Controller
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job); var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound(); if (reworkRecord == null) return NotFound();
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId); var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (originalJob == null) return NotFound(); if (originalJob == null) return NotFound();
var companyId = originalJob.CompanyId; var companyId = originalJob.CompanyId;
@@ -3869,7 +4013,7 @@ public class JobsController : Controller
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First(); var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Sub-number: {parentJobNumber}-R{n+1} // Sub-number: {parentJobNumber}-R{n+1}
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id); var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id, companyId);
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}"; var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
var reworkJob = new Job var reworkJob = new Job
@@ -4021,7 +4165,7 @@ public class JobsController : Controller
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> ResyncFromQuote(int id) public async Task<IActionResult> ResyncFromQuote(int id)
{ {
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id); var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return NotFound(); if (job == null) return NotFound();
// Guard: only allow re-sync while job is pre-production // Guard: only allow re-sync while job is pre-production
@@ -4061,7 +4205,7 @@ public class JobsController : Controller
// Load quote items with full coat + prep-service data // Load quote items with full coat + prep-service data
var quote = job.Quote!; var quote = job.Quote!;
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value); var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value, job.CompanyId);
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted)) foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
{ {
@@ -4399,75 +4543,7 @@ public class JobsController : Controller
_logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId); _logger.LogInformation("Recorded first job creation for company {CompanyId}", companyId);
} }
/// <summary> // LogMaterial has been consolidated into InventoryController.LogMaterial.
/// Logs manual material usage from the job details page. Mirrors the QR scan LogUsage
/// flow in InventoryController but returns JSON so the modal can close and refresh inline.
/// Quantity is always the amount USED (caller converts from remaining if needed).
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> LogMaterial([FromBody] LogMaterialRequest req)
{
try
{
if (req.QuantityUsed <= 0)
return Json(new { success = false, message = "Quantity used must be greater than zero." });
var item = await _unitOfWork.InventoryItems.GetByIdAsync(req.InventoryItemId);
if (item == null) return Json(new { success = false, message = "Inventory item not found." });
var job = await _unitOfWork.Jobs.GetByIdAsync(req.JobId);
if (job == null) return Json(new { success = false, message = "Job not found." });
var txnType = req.TransactionType == "Waste"
? InventoryTransactionType.Waste
: InventoryTransactionType.JobUsage;
item.QuantityOnHand -= req.QuantityUsed;
item.UpdatedAt = DateTime.UtcNow;
await _unitOfWork.InventoryItems.UpdateAsync(item);
var txn = new PowderCoating.Core.Entities.InventoryTransaction
{
InventoryItemId = item.Id,
TransactionType = txnType,
Quantity = -req.QuantityUsed,
UnitCost = item.UnitCost,
TotalCost = req.QuantityUsed * item.UnitCost,
TransactionDate = DateTime.UtcNow,
BalanceAfter = item.QuantityOnHand,
JobId = req.JobId,
Reference = $"Job {job.JobNumber}",
Notes = req.Notes?.Trim(),
CompanyId = item.CompanyId,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.InventoryTransactions.AddAsync(txn);
await _unitOfWork.CompleteAsync();
// GL: DR COGS, CR Inventory Asset
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
{
var cost = req.QuantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
}
return Json(new
{
success = true,
message = $"Logged {req.QuantityUsed:N2} {item.UnitOfMeasure} of {item.Name}.",
newBalance = item.QuantityOnHand,
unitOfMeasure = item.UnitOfMeasure,
itemName = item.Name
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error logging material for job {JobId}", req.JobId);
return Json(new { success = false, message = "An error occurred. Please try again." });
}
}
/// <summary> /// <summary>
/// Inline-edits description, quantity, and unit price on a single job line item. /// Inline-edits description, quantity, and unit price on a single job line item.
@@ -4554,14 +4630,6 @@ public class PatchJobItemRequest
public decimal Quantity { get; set; } public decimal Quantity { get; set; }
public decimal UnitPrice { get; set; } public decimal UnitPrice { get; set; }
} }
public class LogMaterialRequest
{
public int JobId { get; set; }
public int InventoryItemId { get; set; }
public decimal QuantityUsed { get; set; }
public string TransactionType { get; set; } = "JobUsage";
public string? Notes { get; set; }
}
public class CreateReworkJobRequest public class CreateReworkJobRequest
{ {
public int ReworkRecordId { get; set; } public int ReworkRecordId { get; set; }
@@ -26,6 +26,7 @@ namespace PowderCoating.Web.Controllers;
/// When creating new Customer or Job records from the kiosk, CompanyId is set explicitly /// When creating new Customer or Job records from the kiosk, CompanyId is set explicitly
/// from session.CompanyId so the EF SaveChanges interceptor doesn't override it with 0. /// from session.CompanyId so the EF SaveChanges interceptor doesn't override it with 0.
/// </summary> /// </summary>
[Authorize]
public class KioskController : Controller public class KioskController : Controller
{ {
private const string CookieName = "KioskDevice"; private const string CookieName = "KioskDevice";
@@ -288,8 +288,9 @@ public class QuotesController : Controller
try try
{ {
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load quote with all navigations needed for the Details view // Load quote with all navigations needed for the Details view
var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value); var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value, companyId);
if (quote == null) if (quote == null)
{ {
@@ -412,7 +413,7 @@ public class QuotesController : Controller
}; };
// Load change history // Load change history
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value); var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value, companyId);
var changeHistoryDtos = _mapper.Map<List<QuoteChangeHistoryDto>>(changeHistories); var changeHistoryDtos = _mapper.Map<List<QuoteChangeHistoryDto>>(changeHistories);
ViewBag.ChangeHistory = changeHistoryDtos; ViewBag.ChangeHistory = changeHistoryDtos;
@@ -560,7 +561,7 @@ public class QuotesController : Controller
} }
} }
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems); quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
// Get company info and logo // Get company info and logo
@@ -1082,7 +1083,7 @@ public class QuotesController : Controller
_logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id); _logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id);
} }
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id); var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id, currentUser!.CompanyId);
this.SetNotificationResultToast(quoteCreateNotifLog); this.SetNotificationResultToast(quoteCreateNotifLog);
} }
@@ -1135,7 +1136,7 @@ public class QuotesController : Controller
} }
// Get quote items with their coats, prep services and catalog item // Get quote items with their coats, prep services and catalog item
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
_logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value); _logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value);
foreach (var item in quoteItems) foreach (var item in quoteItems)
@@ -1957,12 +1958,10 @@ public class QuotesController : Controller
if (dto.SmsConsent) if (dto.SmsConsent)
await _notificationService.NotifySmsConsentGrantedAsync(customer); await _notificationService.NotifySmsConsentGrantedAsync(customer);
// Get "Converted" status (cached) // Update quote to link to new customer.
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Do NOT set "Converted" status here — that status is reserved for when a job is
var statuses = await _lookupCache.GetQuoteStatusLookupsAsync(companyId); // actually created via CreateJobFromQuote. Keeping the quote at "Approved" lets the
var convertedStatus = statuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Quote.Converted); // user immediately click "Create Job from Quote" on the next screen.
// Update quote to link to new customer
quote.CustomerId = customer.Id; quote.CustomerId = customer.Id;
// Clear prospect fields // Clear prospect fields
@@ -1977,14 +1976,11 @@ public class QuotesController : Controller
quote.ProspectSmsConsent = false; quote.ProspectSmsConsent = false;
quote.ProspectSmsConsentedAt = null; quote.ProspectSmsConsentedAt = null;
// Update status to converted
quote.QuoteStatusId = convertedStatus?.Id ?? quote.QuoteStatusId;
await _unitOfWork.Quotes.UpdateAsync(quote); await _unitOfWork.Quotes.UpdateAsync(quote);
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
this.ToastSuccess($"Prospect/Walk-In successfully converted to customer! Quote {quote.QuoteNumber} has been updated."); this.ToastSuccess($"Customer record created! You can now create a job from quote {quote.QuoteNumber}.");
return RedirectToAction("Details", "Customers", new { id = customer.Id }); return RedirectToAction(nameof(Details), new { id = dto.QuoteId });
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -2202,7 +2198,7 @@ public class QuotesController : Controller
} }
} }
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value); var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems); quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
// Warn on confirmation page if a job is linked // Warn on confirmation page if a job is linked
@@ -2361,7 +2357,7 @@ public class QuotesController : Controller
_logger.LogWarning(ex, "Notification failed for quote {Id}", id); _logger.LogWarning(ex, "Notification failed for quote {Id}", id);
} }
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id, quote.CompanyId);
this.SetNotificationResultToast(approveNotifLog); this.SetNotificationResultToast(approveNotifLog);
} }
@@ -2723,7 +2719,7 @@ public class QuotesController : Controller
} }
} }
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId); var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId, currentUser.CompanyId);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems); quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId); var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -2897,7 +2893,7 @@ public class QuotesController : Controller
// Always reload quote items with full coat/prep-service data so this works // Always reload quote items with full coat/prep-service data so this works
// regardless of which caller loaded the quote (some callers don't include coats). // regardless of which caller loaded the quote (some callers don't include coats).
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id); var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id, quote.CompanyId);
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning // Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts. // no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
@@ -2958,6 +2954,7 @@ public class QuotesController : Controller
Total = quote.Total Total = quote.Total
}), }),
CustomerPO = quote.CustomerPO, CustomerPO = quote.CustomerPO,
ProjectName = quote.ProjectName,
InternalNotes = quote.Notes, // Copy internal notes from quote InternalNotes = quote.Notes, // Copy internal notes from quote
IsCustomerApproved = true, IsCustomerApproved = true,
IsRushJob = quote.IsRushJob, IsRushJob = quote.IsRushJob,
@@ -3214,7 +3211,7 @@ public class QuotesController : Controller
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride); await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
// Check the most recent log entry to get actual send status // Check the most recent log entry to get actual send status
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id); var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id, quote.CompanyId);
if (latestLog?.Status == NotificationStatus.Failed) if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" }); return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
@@ -3316,7 +3313,7 @@ public class QuotesController : Controller
public async Task<IActionResult> NotificationsSent(int id) public async Task<IActionResult> NotificationsSent(int id)
{ {
var tz = ViewBag.CompanyTimeZone as string; var tz = ViewBag.CompanyTimeZone as string;
var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id); var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(), var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage,
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") }); SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
@@ -3435,13 +3432,21 @@ public class QuotesController : Controller
// Build company AI context: profile text + recent accepted predictions as few-shot examples // Build company AI context: profile text + recent accepted predictions as few-shot examples
var aiContext = await BuildCompanyAiContextAsync(companyId, costs); var aiContext = await BuildCompanyAiContextAsync(companyId, costs);
// Load the specific blast setup when the user picked one before analyzing // Load the specific blast setup when the user picked one before analyzing.
// If none was explicitly chosen, fall back to the company's default blast setup so
// named-setup rates (e.g. a blast cabinet configured at 82 sqft/hr) are always
// used instead of the coarser company-level operating cost fallback.
CompanyBlastSetup? selectedBlastSetup = null; CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue) if (request.BlastSetupId.HasValue)
{ {
var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId); var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = setups.FirstOrDefault(); selectedBlastSetup = setups.FirstOrDefault();
} }
else
{
var defaultSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsDefault && b.IsActive && b.CompanyId == companyId);
selectedBlastSetup = defaultSetups.FirstOrDefault();
}
var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup); var result = await _aiService.AnalyzeItemAsync(request, photos, costs, avgPowderCost, aiContext, selectedBlastSetup);
await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length)); await _usageLogger.LogAsync(companyId, user?.Id ?? "", AppConstants.AiFeatures.PhotoQuote, result.Success, photos.Sum(p => p.Data.Length));
@@ -16,6 +16,7 @@ namespace PowderCoating.Web.Controllers;
/// SuperAdmins because only platform staff should author release content. /// SuperAdmins because only platform staff should author release content.
/// </para> /// </para>
/// </summary> /// </summary>
[Authorize]
public class ReleaseNotesController : Controller public class ReleaseNotesController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
@@ -34,7 +35,6 @@ public class ReleaseNotesController : Controller
/// newest-first. Drafts are invisible to ordinary users so SuperAdmins can /// newest-first. Drafts are invisible to ordinary users so SuperAdmins can
/// prepare notes in advance without surfacing them prematurely. /// prepare notes in advance without surfacing them prematurely.
/// </summary> /// </summary>
[Authorize]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var notes = (await _unitOfWork.ReleaseNotes.FindAsync(r => r.IsPublished)) var notes = (await _unitOfWork.ReleaseNotes.FindAsync(r => r.IsPublished))
@@ -146,12 +146,19 @@ public class ReportsController : Controller
var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m; var momGrowth = revenueLastMonth > 0 ? Math.Round((revenueThisMonth - revenueLastMonth) / revenueLastMonth * 100, 1) : 0m;
// === REVENUE ANALYTICS === // === REVENUE ANALYTICS ===
// Pre-filter completed jobs by date range once for monthly calculations // CompletedDate is the authoritative "when the job finished" date.
var completedJobsInRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList(); // UpdatedAt is set by the EF interceptor only on Modified saves, so seeded/imported
// jobs may have UpdatedAt = null. Fall back to CreatedAt as a last resort.
static DateTime JobMonthDate(Job j) =>
j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt;
var completedJobsInRange = completedJobs
.Where(j => JobMonthDate(j) >= startDate)
.ToList();
// Group by month for efficient monthly aggregations // Group by month for efficient monthly aggregations
var jobsByMonth = completedJobsInRange var jobsByMonth = completedJobsInRange
.GroupBy(j => new DateTime(j.UpdatedAt.Value.Year, j.UpdatedAt.Value.Month, 1)) .GroupBy(j => { var d = JobMonthDate(j); return new DateTime(d.Year, d.Month, 1); })
.ToDictionary(g => g.Key, g => g.ToList()); .ToDictionary(g => g.Key, g => g.ToList());
// Monthly revenue trend // Monthly revenue trend
@@ -213,12 +220,13 @@ public class ReportsController : Controller
// Top customers by revenue // Top customers by revenue
var topCustomers = completedJobs var topCustomers = completedJobs
.Where(j => j.Customer != null) .Where(j => j.Customer != null)
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem .Select(g => new TopCustomerItem
{ {
Id = g.Key.Id, Id = g.Key,
Name = g.Key.CompanyName, Name = g.First().Customer!.CompanyName
Revenue = g.Sum(j => j.FinalPrice), ?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count() JobCount = g.Count()
}) })
.OrderByDescending(x => x.Revenue) .OrderByDescending(x => x.Revenue)
@@ -440,7 +448,9 @@ public class ReportsController : Controller
.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem .SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem
{ {
InvoiceNumber = i.InvoiceNumber, InvoiceNumber = i.InvoiceNumber,
CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty, CustomerName = i.Customer?.CompanyName
?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim()
?? string.Empty,
Amount = p.Amount, Amount = p.Amount,
PaymentMethod = p.PaymentMethod.ToString(), PaymentMethod = p.PaymentMethod.ToString(),
PaymentDate = p.PaymentDate PaymentDate = p.PaymentDate
@@ -1353,8 +1363,15 @@ public class ReportsController : Controller
monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m); monthlyRevenue.Add(jobsByMonth.TryGetValue(ms, out var mj) ? mj.Sum(j => j.FinalPrice) : 0m);
} }
var topCustomers = completedJobs.Where(j => j.Customer != null) var topCustomers = completedJobs.Where(j => j.Customer != null)
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() }) .Select(g => new TopCustomerItem
{
Id = g.Key,
Name = g.First().Customer!.CompanyName
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count()
})
.OrderByDescending(x => x.Revenue).Take(5).ToList(); .OrderByDescending(x => x.Revenue).Take(5).ToList();
var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName) var jobsByStatus = jobs.GroupBy(j => j.JobStatus.DisplayName)
.OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder) .OrderBy(g => jobs.First(j => j.JobStatus.DisplayName == g.Key).JobStatus.DisplayOrder)
@@ -1408,8 +1425,15 @@ public class ReportsController : Controller
.OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder) .OrderBy(g => completedJobs.First(j => j.JobPriority.DisplayName == g.Key).JobPriority.DisplayOrder)
.ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice)); .ToDictionary(g => g.Key, g => g.Sum(j => j.FinalPrice));
var topCustomers = completedJobs.Where(j => j.Customer != null) var topCustomers = completedJobs.Where(j => j.Customer != null)
.GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) .GroupBy(j => j.Customer!.Id)
.Select(g => new TopCustomerItem { Id = g.Key.Id, Name = g.Key.CompanyName, Revenue = g.Sum(j => j.FinalPrice), JobCount = g.Count() }) .Select(g => new TopCustomerItem
{
Id = g.Key,
Name = g.First().Customer!.CompanyName
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
Revenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count()
})
.OrderByDescending(x => x.Revenue).Take(10).ToList(); .OrderByDescending(x => x.Revenue).Take(10).ToList();
return View(new RevenueTrendsViewModel return View(new RevenueTrendsViewModel
{ {
@@ -1501,8 +1525,17 @@ public class ReportsController : Controller
var customersWithMultiple = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1); var customersWithMultiple = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id).Count(g => g.Count() > 1);
var totalWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count(); var totalWithJobs = completedJobs.Where(j => j.Customer != null).Select(j => j.Customer!.Id).Distinct().Count();
var retentionRate = totalWithJobs > 0 ? Math.Round((decimal)customersWithMultiple / totalWithJobs * 100, 1) : 0m; var retentionRate = totalWithJobs > 0 ? Math.Round((decimal)customersWithMultiple / totalWithJobs * 100, 1) : 0m;
var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => new { j.Customer!.Id, j.Customer.CompanyName }) var clv = completedJobs.Where(j => j.Customer != null).GroupBy(j => j.Customer!.Id)
.Select(g => new CustomerLifetimeValueItem { CustomerName = g.Key.CompanyName, TotalRevenue = g.Sum(j => j.FinalPrice), JobCount = g.Count(), AvgOrderValue = g.Average(j => j.FinalPrice), FirstJobDate = g.Min(j => j.CreatedAt), LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt) }) .Select(g => new CustomerLifetimeValueItem
{
CustomerName = g.First().Customer!.CompanyName
?? $"{g.First().Customer!.ContactFirstName} {g.First().Customer!.ContactLastName}".Trim(),
TotalRevenue = g.Sum(j => j.FinalPrice),
JobCount = g.Count(),
AvgOrderValue = g.Average(j => j.FinalPrice),
FirstJobDate = g.Min(j => j.CreatedAt),
LastJobDate = g.Max(j => j.CompletedDate ?? j.UpdatedAt ?? j.CreatedAt)
})
.OrderByDescending(c => c.TotalRevenue).Take(10).ToList(); .OrderByDescending(c => c.TotalRevenue).Take(10).ToList();
var quotesByStatus = quotes.GroupBy(q => q.QuoteStatus.DisplayName).OrderBy(g => quotes.First(q => q.QuoteStatus.DisplayName == g.Key).QuoteStatus.DisplayOrder).ToDictionary(g => g.Key, g => g.Count()); var quotesByStatus = quotes.GroupBy(q => q.QuoteStatus.DisplayName).OrderBy(g => quotes.First(q => q.QuoteStatus.DisplayName == g.Key).QuoteStatus.DisplayOrder).ToDictionary(g => g.Key, g => g.Count());
var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") }; var quoteFunnel = new QuoteConversionFunnel { Draft = quotes.Count(q => q.QuoteStatus.StatusCode == "DRAFT"), Sent = quotes.Count(q => q.QuoteStatus.StatusCode == "SENT"), Approved = quotes.Count(q => q.QuoteStatus.StatusCode == "APPROVED"), Converted = quotes.Count(q => q.QuoteStatus.StatusCode == "CONVERTED"), Rejected = quotes.Count(q => q.QuoteStatus.StatusCode == "REJECTED"), Expired = quotes.Count(q => q.QuoteStatus.StatusCode == "EXPIRED") };
@@ -1542,7 +1575,7 @@ public class ReportsController : Controller
var agingBuckets = new List<AgingBucketItem> { new() { Label = "Current (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 days" }, new() { Label = "Over 90 days" } }; var agingBuckets = new List<AgingBucketItem> { new() { Label = "Current (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 days" }, new() { Label = "Over 90 days" } };
foreach (var inv in outstandingInvoices) { var days = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; agingBuckets[b].Amount += balance; agingBuckets[b].Count++; } foreach (var inv in outstandingInvoices) { var days = inv.DueDate.HasValue ? (int)(today - inv.DueDate.Value).TotalDays : 0; var balance = inv.BalanceDue; var b = days <= 30 ? 0 : days <= 60 ? 1 : days <= 90 ? 2 : 3; agingBuckets[b].Amount += balance; agingBuckets[b].Count++; }
var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? i.Customer?.ContactLastName ?? string.Empty, Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList(); var recentPayments = activeInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted).Select(p => new RecentPaymentItem { InvoiceNumber = i.InvoiceNumber, CustomerName = i.Customer?.CompanyName ?? $"{i.Customer?.ContactFirstName} {i.Customer?.ContactLastName}".Trim(), Amount = p.Amount, PaymentMethod = p.PaymentMethod.ToString(), PaymentDate = p.PaymentDate })).OrderByDescending(p => p.PaymentDate).Take(10).ToList();
var topOutstanding = outstandingInvoices.Where(i => i.Customer != null).GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }).Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }).OrderByDescending(x => x.OutstandingBalance).Take(5).ToList(); var topOutstanding = outstandingInvoices.Where(i => i.Customer != null).GroupBy(i => new { i.CustomerId, Name = i.Customer!.CompanyName ?? $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() }).Select(g => new OutstandingCustomerItem { CustomerName = g.Key.Name, OutstandingBalance = g.Sum(i => i.BalanceDue), OpenInvoiceCount = g.Count() }).OrderByDescending(x => x.OutstandingBalance).Take(5).ToList();
var paidWithDates = allInvoices.Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue).ToList(); var paidWithDates = allInvoices.Where(i => i.Status == InvoiceStatus.Paid && i.SentDate.HasValue && i.PaidDate.HasValue).ToList();
var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0; var avgDays = paidWithDates.Any() ? paidWithDates.Average(i => (i.PaidDate!.Value - i.SentDate!.Value).TotalDays) : 0.0;
@@ -106,6 +106,83 @@ public class SeedDataController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
/// <summary>
/// Wipes all seeded data from the DEMO company and immediately re-seeds it with fresh demo data
/// so all dates are current. Intended for tutorial recording resets — one click returns the demo
/// company to a clean, realistic state without touching any other tenant.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetDemoCompany()
{
try
{
var companies = await _seedDataService.GetCompaniesAsync();
var demo = companies.FirstOrDefault(c => c.CompanyCode == "DEMO");
if (demo == null)
{
TempData["ErrorMessage"] = "Demo company (code: DEMO) not found. Run Seed System Data first.";
return RedirectToAction(nameof(Index));
}
// Full wipe — ForceRemoveAll bypasses fingerprint matching so stale seed data from
// previous code versions (different emails, renamed SKUs, etc.) is always cleared.
var removeOptions = new RemoveSeedDataOptions
{
Customers = true,
InventoryItems = true,
Equipment = true,
Catalog = true,
PricingTiers = true,
OperatingCosts = true,
Bills = true,
Expenses = true,
Workers = false, // workers stay static — never deleted on reset
Vendors = true,
NamedOvens = true,
Appointments = true,
ForceRemoveAll = true,
};
var removeResult = await _seedDataService.RemoveSeedDataAsync(demo.Id, removeOptions);
if (!removeResult.Success)
{
TempData["ErrorMessage"] = $"Wipe step failed: {removeResult.Message}";
return RedirectToAction(nameof(Index));
}
// Re-seed with today's dates
var seedResult = await _seedDataService.SeedCompanyDataAsync(demo.Id);
if (seedResult.Success)
{
TempData["SuccessMessage"] = $"Demo company reset complete. {seedResult.ItemsSeeded} records re-seeded with today's dates.";
TempData["SeedDetails"] = string.Join("|", seedResult.Details);
TempData["ItemsSeeded"] = seedResult.ItemsSeeded;
if (seedResult.Warnings.Any())
{
TempData["WarningMessage"] = $"{seedResult.ItemsSkipped} item(s) were skipped";
var displayWarnings = seedResult.Warnings.Take(30).ToList();
if (seedResult.Warnings.Count > 30)
displayWarnings.Add($"... and {seedResult.Warnings.Count - 30} more (see logs)");
TempData["SeedWarnings"] = string.Join("|", displayWarnings);
}
}
else
{
TempData["ErrorMessage"] = $"Wipe succeeded but re-seed failed: {seedResult.Message}";
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resetting demo company");
TempData["ErrorMessage"] = $"An error occurred during demo reset: {ex.Message}";
}
return RedirectToAction(nameof(Index));
}
/// <summary> /// <summary>
/// Removes previously seeded demo data from a company according to the supplied options (e.g., jobs only, or all data). Used during QA/demo resets to return a company to a clean state without a full database drop. /// Removes previously seeded demo data from a company according to the supplied options (e.g., jobs only, or all data). Used during QA/demo resets to return a company to a clean state without a full database drop.
/// </summary> /// </summary>
@@ -2092,6 +2092,27 @@ public class ToolsController : Controller
{ {
await writer.WriteAsync(purchaseOrdersCsv); await writer.WriteAsync(purchaseOrdersCsv);
} }
// 16. Company Settings
var settingsCompany = await _unitOfWork.Companies.GetByIdAsync(companyId.Value, false,
c => c.OperatingCosts, c => c.Preferences, c => c.PricingTiers);
if (settingsCompany != null)
{
var settingsJobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var settingsJobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
var settingsQuoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var settingsInventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
var settingsApptStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var settingsApptTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
var settingsCsv = GenerateCompanySettingsCsv(settingsCompany, settingsJobStatuses, settingsJobPriorities,
settingsQuoteStatuses, settingsInventoryCategories, settingsApptStatuses, settingsApptTypes);
var settingsEntry = archive.CreateEntry($"company_settings_{timestamp}.csv");
using (var entryStream = settingsEntry.Open())
using (var writer = new System.IO.StreamWriter(entryStream))
{
await writer.WriteAsync(settingsCsv);
}
}
} }
memoryStream.Position = 0; memoryStream.Position = 0;
@@ -2785,23 +2806,46 @@ public class ToolsController : Controller
sb.AppendLine("State,"); sb.AppendLine("State,");
sb.AppendLine("ZipCode,"); sb.AppendLine("ZipCode,");
sb.AppendLine("TimeZone,America/New_York"); sb.AppendLine("TimeZone,America/New_York");
sb.AppendLine("AccountingMethod,Accrual");
sb.AppendLine("TimeclockEnabled,true");
sb.AppendLine("TimeclockAllowMultiplePunchesPerDay,true");
sb.AppendLine("TimeclockAutoClockOutHours,");
sb.AppendLine(); sb.AppendLine();
// Operating Costs // Operating Costs
sb.AppendLine("[Operating Costs]"); sb.AppendLine("[Operating Costs]");
sb.AppendLine("StandardLaborRate,65.00"); sb.AppendLine("StandardLaborRate,65.00");
sb.AppendLine("LaborCostPerHour,");
sb.AppendLine("AdditionalCoatLaborPercent,30"); sb.AppendLine("AdditionalCoatLaborPercent,30");
sb.AppendLine("OvenOperatingCostPerHour,25.00"); sb.AppendLine("OvenOperatingCostPerHour,25.00");
sb.AppendLine("DefaultOvenCycleMinutes,45");
sb.AppendLine("SandblasterCostPerHour,35.00"); sb.AppendLine("SandblasterCostPerHour,35.00");
sb.AppendLine("CoatingBoothCostPerHour,30.00"); sb.AppendLine("CoatingBoothCostPerHour,30.00");
sb.AppendLine("PowderCoatingCostPerSqFt,0.50"); sb.AppendLine("PowderCoatingCostPerSqFt,0.50");
sb.AppendLine("PricingMode,MarkupOnMaterial");
sb.AppendLine("GeneralMarkupPercentage,35"); sb.AppendLine("GeneralMarkupPercentage,35");
sb.AppendLine("TargetMarginPercent,0");
sb.AppendLine("TaxPercent,8.5"); sb.AppendLine("TaxPercent,8.5");
sb.AppendLine("ShopSuppliesRate,5"); sb.AppendLine("ShopSuppliesRate,5");
sb.AppendLine("RushChargeType,Percentage"); sb.AppendLine("RushChargeType,Percentage");
sb.AppendLine("RushChargePercentage,25"); sb.AppendLine("RushChargePercentage,25");
sb.AppendLine("RushChargeFixedAmount,0"); sb.AppendLine("RushChargeFixedAmount,0");
sb.AppendLine("ShopMinimumCharge,50.00"); sb.AppendLine("ShopMinimumCharge,50.00");
sb.AppendLine("ComplexitySimplePercent,0");
sb.AppendLine("ComplexityModeratePercent,5");
sb.AppendLine("ComplexityComplexPercent,15");
sb.AppendLine("ComplexityExtremePercent,25");
sb.AppendLine("ShopCapabilityTier,Small");
sb.AppendLine("BlastSetupType,SiphonCabinet");
sb.AppendLine("CompressorCfm,0");
sb.AppendLine("BlastNozzleSize,4");
sb.AppendLine("PrimaryBlastSubstrate,Mixed");
sb.AppendLine("BlastRateSqFtPerHourOverride,");
sb.AppendLine("CoatingGunType,Corona");
sb.AppendLine("CoatingRateSqFtPerHourOverride,");
sb.AppendLine("MonthlyRent,0");
sb.AppendLine("MonthlyUtilities,0");
sb.AppendLine("MonthlyBillableHours,160");
sb.AppendLine(); sb.AppendLine();
// Preferences // Preferences
@@ -2813,16 +2857,22 @@ public class ToolsController : Controller
sb.AppendLine("DefaultQuoteValidityDays,30"); sb.AppendLine("DefaultQuoteValidityDays,30");
sb.AppendLine("QuoteNumberPrefix,QT"); sb.AppendLine("QuoteNumberPrefix,QT");
sb.AppendLine("JobNumberPrefix,JOB"); sb.AppendLine("JobNumberPrefix,JOB");
sb.AppendLine("InvoiceNumberPrefix,INV");
sb.AppendLine("UseMetricSystem,false"); sb.AppendLine("UseMetricSystem,false");
sb.AppendLine("DefaultJobPriority,Normal"); sb.AppendLine("DefaultJobPriority,Normal");
sb.AppendLine("RequireCustomerPO,false"); sb.AppendLine("RequireCustomerPO,false");
sb.AppendLine("AllowCustomerApproval,true"); sb.AppendLine("AllowCustomerApproval,true");
sb.AppendLine("DefaultTurnaroundDays,7"); sb.AppendLine("DefaultTurnaroundDays,7");
sb.AppendLine("EmailFromAddress,");
sb.AppendLine("EmailFromName,");
sb.AppendLine("EmailNotificationsEnabled,true"); sb.AppendLine("EmailNotificationsEnabled,true");
sb.AppendLine("NotifyOnNewJob,true"); sb.AppendLine("NotifyOnNewJob,true");
sb.AppendLine("NotifyOnNewQuote,true");
sb.AppendLine("NotifyOnJobStatusChange,true"); sb.AppendLine("NotifyOnJobStatusChange,true");
sb.AppendLine("NotifyOnQuoteApproval,true"); sb.AppendLine("NotifyOnQuoteApproval,true");
sb.AppendLine("NotifyOnPaymentReceived,true"); sb.AppendLine("NotifyOnPaymentReceived,true");
sb.AppendLine("PaymentRemindersEnabled,false");
sb.AppendLine("PaymentReminderDays,7,14,30");
sb.AppendLine("QuoteExpiryWarningDays,3"); sb.AppendLine("QuoteExpiryWarningDays,3");
sb.AppendLine("DueDateWarningDays,2"); sb.AppendLine("DueDateWarningDays,2");
sb.AppendLine("MaintenanceAlertDays,7"); sb.AppendLine("MaintenanceAlertDays,7");
@@ -2831,6 +2881,16 @@ public class ToolsController : Controller
sb.AppendLine("LogRetentionDays,90"); sb.AppendLine("LogRetentionDays,90");
sb.AppendLine("AutoArchiveJobsDays,365"); sb.AppendLine("AutoArchiveJobsDays,365");
sb.AppendLine("DeletedRecordRetentionDays,30"); sb.AppendLine("DeletedRecordRetentionDays,30");
sb.AppendLine("QtAccentColor,#374151");
sb.AppendLine("QtDefaultTerms,");
sb.AppendLine("QtFooterNote,");
sb.AppendLine("InAccentColor,#374151");
sb.AppendLine("InDefaultTerms,");
sb.AppendLine("InFooterNote,");
sb.AppendLine("WoAccentColor,#374151");
sb.AppendLine("WoTerms,");
sb.AppendLine("KioskIntakeOutput,Quote");
sb.AppendLine("MigratingFromQuickBooks,false");
sb.AppendLine(); sb.AppendLine();
// Pricing Tiers // Pricing Tiers
@@ -2925,6 +2985,10 @@ public class ToolsController : Controller
sb.AppendLine($"State,{EscapeCsv(company.State)}"); sb.AppendLine($"State,{EscapeCsv(company.State)}");
sb.AppendLine($"ZipCode,{EscapeCsv(company.ZipCode)}"); sb.AppendLine($"ZipCode,{EscapeCsv(company.ZipCode)}");
sb.AppendLine($"TimeZone,{EscapeCsv(company.TimeZone)}"); sb.AppendLine($"TimeZone,{EscapeCsv(company.TimeZone)}");
sb.AppendLine($"AccountingMethod,{company.AccountingMethod}");
sb.AppendLine($"TimeclockEnabled,{company.TimeclockEnabled.ToString().ToLower()}");
sb.AppendLine($"TimeclockAllowMultiplePunchesPerDay,{company.TimeclockAllowMultiplePunchesPerDay.ToString().ToLower()}");
sb.AppendLine($"TimeclockAutoClockOutHours,{company.TimeclockAutoClockOutHours?.ToString() ?? ""}");
sb.AppendLine(); sb.AppendLine();
// Operating Costs // Operating Costs
@@ -2933,18 +2997,37 @@ public class ToolsController : Controller
var costs = company.OperatingCosts; var costs = company.OperatingCosts;
sb.AppendLine("[Operating Costs]"); sb.AppendLine("[Operating Costs]");
sb.AppendLine($"StandardLaborRate,{costs.StandardLaborRate}"); sb.AppendLine($"StandardLaborRate,{costs.StandardLaborRate}");
sb.AppendLine($"LaborCostPerHour,{costs.LaborCostPerHour?.ToString() ?? ""}");
sb.AppendLine($"AdditionalCoatLaborPercent,{costs.AdditionalCoatLaborPercent}"); sb.AppendLine($"AdditionalCoatLaborPercent,{costs.AdditionalCoatLaborPercent}");
sb.AppendLine($"OvenOperatingCostPerHour,{costs.OvenOperatingCostPerHour}"); sb.AppendLine($"OvenOperatingCostPerHour,{costs.OvenOperatingCostPerHour}");
sb.AppendLine($"DefaultOvenCycleMinutes,{costs.DefaultOvenCycleMinutes}");
sb.AppendLine($"SandblasterCostPerHour,{costs.SandblasterCostPerHour}"); sb.AppendLine($"SandblasterCostPerHour,{costs.SandblasterCostPerHour}");
sb.AppendLine($"CoatingBoothCostPerHour,{costs.CoatingBoothCostPerHour}"); sb.AppendLine($"CoatingBoothCostPerHour,{costs.CoatingBoothCostPerHour}");
sb.AppendLine($"PowderCoatingCostPerSqFt,{costs.PowderCoatingCostPerSqFt}"); sb.AppendLine($"PowderCoatingCostPerSqFt,{costs.PowderCoatingCostPerSqFt}");
sb.AppendLine($"PricingMode,{costs.PricingMode}");
sb.AppendLine($"GeneralMarkupPercentage,{costs.GeneralMarkupPercentage}"); sb.AppendLine($"GeneralMarkupPercentage,{costs.GeneralMarkupPercentage}");
sb.AppendLine($"TargetMarginPercent,{costs.TargetMarginPercent}");
sb.AppendLine($"TaxPercent,{costs.TaxPercent}"); sb.AppendLine($"TaxPercent,{costs.TaxPercent}");
sb.AppendLine($"ShopSuppliesRate,{costs.ShopSuppliesRate}"); sb.AppendLine($"ShopSuppliesRate,{costs.ShopSuppliesRate}");
sb.AppendLine($"RushChargeType,{EscapeCsv(costs.RushChargeType)}"); sb.AppendLine($"RushChargeType,{EscapeCsv(costs.RushChargeType)}");
sb.AppendLine($"RushChargePercentage,{costs.RushChargePercentage}"); sb.AppendLine($"RushChargePercentage,{costs.RushChargePercentage}");
sb.AppendLine($"RushChargeFixedAmount,{costs.RushChargeFixedAmount}"); sb.AppendLine($"RushChargeFixedAmount,{costs.RushChargeFixedAmount}");
sb.AppendLine($"ShopMinimumCharge,{costs.ShopMinimumCharge}"); sb.AppendLine($"ShopMinimumCharge,{costs.ShopMinimumCharge}");
sb.AppendLine($"ComplexitySimplePercent,{costs.ComplexitySimplePercent}");
sb.AppendLine($"ComplexityModeratePercent,{costs.ComplexityModeratePercent}");
sb.AppendLine($"ComplexityComplexPercent,{costs.ComplexityComplexPercent}");
sb.AppendLine($"ComplexityExtremePercent,{costs.ComplexityExtremePercent}");
sb.AppendLine($"ShopCapabilityTier,{costs.ShopCapabilityTier}");
sb.AppendLine($"BlastSetupType,{costs.BlastSetupType}");
sb.AppendLine($"CompressorCfm,{costs.CompressorCfm}");
sb.AppendLine($"BlastNozzleSize,{costs.BlastNozzleSize}");
sb.AppendLine($"PrimaryBlastSubstrate,{costs.PrimaryBlastSubstrate}");
sb.AppendLine($"BlastRateSqFtPerHourOverride,{costs.BlastRateSqFtPerHourOverride?.ToString() ?? ""}");
sb.AppendLine($"CoatingGunType,{costs.CoatingGunType}");
sb.AppendLine($"CoatingRateSqFtPerHourOverride,{costs.CoatingRateSqFtPerHourOverride?.ToString() ?? ""}");
sb.AppendLine($"MonthlyRent,{costs.MonthlyRent}");
sb.AppendLine($"MonthlyUtilities,{costs.MonthlyUtilities}");
sb.AppendLine($"MonthlyBillableHours,{costs.MonthlyBillableHours}");
sb.AppendLine(); sb.AppendLine();
} }
@@ -2960,16 +3043,22 @@ public class ToolsController : Controller
sb.AppendLine($"DefaultQuoteValidityDays,{prefs.DefaultQuoteValidityDays}"); sb.AppendLine($"DefaultQuoteValidityDays,{prefs.DefaultQuoteValidityDays}");
sb.AppendLine($"QuoteNumberPrefix,{EscapeCsv(prefs.QuoteNumberPrefix)}"); sb.AppendLine($"QuoteNumberPrefix,{EscapeCsv(prefs.QuoteNumberPrefix)}");
sb.AppendLine($"JobNumberPrefix,{EscapeCsv(prefs.JobNumberPrefix)}"); sb.AppendLine($"JobNumberPrefix,{EscapeCsv(prefs.JobNumberPrefix)}");
sb.AppendLine($"InvoiceNumberPrefix,{EscapeCsv(prefs.InvoiceNumberPrefix)}");
sb.AppendLine($"UseMetricSystem,{prefs.UseMetricSystem.ToString().ToLower()}"); sb.AppendLine($"UseMetricSystem,{prefs.UseMetricSystem.ToString().ToLower()}");
sb.AppendLine($"DefaultJobPriority,{EscapeCsv(prefs.DefaultJobPriority)}"); sb.AppendLine($"DefaultJobPriority,{EscapeCsv(prefs.DefaultJobPriority)}");
sb.AppendLine($"RequireCustomerPO,{prefs.RequireCustomerPO.ToString().ToLower()}"); sb.AppendLine($"RequireCustomerPO,{prefs.RequireCustomerPO.ToString().ToLower()}");
sb.AppendLine($"AllowCustomerApproval,{prefs.AllowCustomerApproval.ToString().ToLower()}"); sb.AppendLine($"AllowCustomerApproval,{prefs.AllowCustomerApproval.ToString().ToLower()}");
sb.AppendLine($"DefaultTurnaroundDays,{prefs.DefaultTurnaroundDays}"); sb.AppendLine($"DefaultTurnaroundDays,{prefs.DefaultTurnaroundDays}");
sb.AppendLine($"EmailFromAddress,{EscapeCsv(prefs.EmailFromAddress)}");
sb.AppendLine($"EmailFromName,{EscapeCsv(prefs.EmailFromName)}");
sb.AppendLine($"EmailNotificationsEnabled,{prefs.EmailNotificationsEnabled.ToString().ToLower()}"); sb.AppendLine($"EmailNotificationsEnabled,{prefs.EmailNotificationsEnabled.ToString().ToLower()}");
sb.AppendLine($"NotifyOnNewJob,{prefs.NotifyOnNewJob.ToString().ToLower()}"); sb.AppendLine($"NotifyOnNewJob,{prefs.NotifyOnNewJob.ToString().ToLower()}");
sb.AppendLine($"NotifyOnNewQuote,{prefs.NotifyOnNewQuote.ToString().ToLower()}");
sb.AppendLine($"NotifyOnJobStatusChange,{prefs.NotifyOnJobStatusChange.ToString().ToLower()}"); sb.AppendLine($"NotifyOnJobStatusChange,{prefs.NotifyOnJobStatusChange.ToString().ToLower()}");
sb.AppendLine($"NotifyOnQuoteApproval,{prefs.NotifyOnQuoteApproval.ToString().ToLower()}"); sb.AppendLine($"NotifyOnQuoteApproval,{prefs.NotifyOnQuoteApproval.ToString().ToLower()}");
sb.AppendLine($"NotifyOnPaymentReceived,{prefs.NotifyOnPaymentReceived.ToString().ToLower()}"); sb.AppendLine($"NotifyOnPaymentReceived,{prefs.NotifyOnPaymentReceived.ToString().ToLower()}");
sb.AppendLine($"PaymentRemindersEnabled,{prefs.PaymentRemindersEnabled.ToString().ToLower()}");
sb.AppendLine($"PaymentReminderDays,{EscapeCsv(prefs.PaymentReminderDays)}");
sb.AppendLine($"QuoteExpiryWarningDays,{prefs.QuoteExpiryWarningDays}"); sb.AppendLine($"QuoteExpiryWarningDays,{prefs.QuoteExpiryWarningDays}");
sb.AppendLine($"DueDateWarningDays,{prefs.DueDateWarningDays}"); sb.AppendLine($"DueDateWarningDays,{prefs.DueDateWarningDays}");
sb.AppendLine($"MaintenanceAlertDays,{prefs.MaintenanceAlertDays}"); sb.AppendLine($"MaintenanceAlertDays,{prefs.MaintenanceAlertDays}");
@@ -2978,6 +3067,16 @@ public class ToolsController : Controller
sb.AppendLine($"LogRetentionDays,{prefs.LogRetentionDays}"); sb.AppendLine($"LogRetentionDays,{prefs.LogRetentionDays}");
sb.AppendLine($"AutoArchiveJobsDays,{prefs.AutoArchiveJobsDays}"); sb.AppendLine($"AutoArchiveJobsDays,{prefs.AutoArchiveJobsDays}");
sb.AppendLine($"DeletedRecordRetentionDays,{prefs.DeletedRecordRetentionDays}"); sb.AppendLine($"DeletedRecordRetentionDays,{prefs.DeletedRecordRetentionDays}");
sb.AppendLine($"QtAccentColor,{EscapeCsv(prefs.QtAccentColor)}");
sb.AppendLine($"QtDefaultTerms,{EscapeCsv(prefs.QtDefaultTerms)}");
sb.AppendLine($"QtFooterNote,{EscapeCsv(prefs.QtFooterNote)}");
sb.AppendLine($"InAccentColor,{EscapeCsv(prefs.InAccentColor)}");
sb.AppendLine($"InDefaultTerms,{EscapeCsv(prefs.InDefaultTerms)}");
sb.AppendLine($"InFooterNote,{EscapeCsv(prefs.InFooterNote)}");
sb.AppendLine($"WoAccentColor,{EscapeCsv(prefs.WoAccentColor)}");
sb.AppendLine($"WoTerms,{EscapeCsv(prefs.WoTerms)}");
sb.AppendLine($"KioskIntakeOutput,{EscapeCsv(prefs.KioskIntakeOutput)}");
sb.AppendLine($"MigratingFromQuickBooks,{prefs.MigratingFromQuickBooks.ToString().ToLower()}");
sb.AppendLine(); sb.AppendLine();
} }
@@ -153,7 +153,7 @@ public static class HelpKnowledgeBase
- *Commercial*: Businesses. Can have a pricing tier, credit limit, tax exempt status, and linked quotes/jobs. - *Commercial*: Businesses. Can have a pricing tier, credit limit, tax exempt status, and linked quotes/jobs.
- *Non-Commercial*: Individual consumers. Simpler setup. - *Non-Commercial*: Individual consumers. Simpler setup.
**Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes. **Key fields:** Name, email, phone, address, customer type, pricing tier, credit limit, tax exempt (with certificate upload), notes, lead source, ship-to address.
**How to add a customer:** **How to add a customer:**
1. Go to [Customers](/Customers) 1. Go to [Customers](/Customers)
@@ -161,10 +161,20 @@ public static class HelpKnowledgeBase
3. Fill in name, contact info, select type 3. Fill in name, contact info, select type
4. Save 4. Save
**Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes. **Customer Details page** (/Customers/Details/ID) shows: contact info, all linked jobs, quotes, invoices, deposits, balance, notes, additional contacts.
**Customer Notes:** Add internal notes on the Details page. Notes are private (not visible to the customer). **Customer Notes:** Add internal notes on the Details page. Notes are private (not visible to the customer).
**Additional Contacts:** Store billing contacts, ops contacts, drop-off contacts, etc. on the Customer Details page. These are for staff reference only all automated notifications (emails, SMS) go to the primary email/phone on the main customer record, not to additional contacts. If invoices need to go to a separate address, use the Billing Email field on the main record.
**Lead Source:** Optional field on the customer record indicating how they found the shop (Walk-In, Google Search, Customer Referral, Social Media, Website, Repeat Customer, Trade Show, Flyer/Print, Other).
**Ship-To Address:** Optional separate address for pickups or deliveries. Shown alongside the billing address on the Customer Details page when set.
**Preferred Powders:** On the Customer Details page, the Preferred Powders card lets staff tag inventory items that a customer regularly orders. Use the search box to find a powder by name or SKU and click Add. Remove with the × button. This is a staff-reference tool only it does not auto-select powders on quotes or jobs. Items must already exist in Inventory to appear in the search.
**Outstanding Pickups (Ready for Pickup card):** When one or more of a customer's jobs are in "Ready for Pickup" status, a highlighted card appears on their Customer Details page showing each job number and how many days it has been waiting. Color coding: amber = 36 days, red = 7+ days. The card disappears once all jobs move out of Ready for Pickup status. Useful for front desk staff to instantly see during a call whether parts are ready for this customer.
**Deactivating a customer:** Use the Delete/Deactivate option this soft-deletes (hides) the customer but does not erase data. **Deactivating a customer:** Use the Delete/Deactivate option this soft-deletes (hides) the customer but does not erase data.
**Pricing Tiers:** Assign a tier (configured at [Pricing Tiers](/PricingTiers)) to automatically apply a discount to that customer's quotes. **Pricing Tiers:** Assign a tier (configured at [Pricing Tiers](/PricingTiers)) to automatically apply a discount to that customer's quotes.
@@ -194,8 +204,9 @@ public static class HelpKnowledgeBase
2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top 2. Choose Quick Quote (fast) or Full Quote (complete form) using the toggle at the top
3. Select existing customer OR enter prospect info (name, email, phone) 3. Select existing customer OR enter prospect info (name, email, phone)
4. Add line items using the item wizard (3 item types below) 4. Add line items using the item wizard (3 item types below)
5. Review the pricing breakdown 5. Optionally enter a **Project Name** a short label (e.g. "Shop Equipment Rack") that carries through to the job and invoice when the quote is converted.
6. Save as Draft or Send immediately 6. Review the pricing breakdown
7. Save as Draft or Send immediately
**Item types in the quote/job wizard:** **Item types in the quote/job wizard:**
1. *Product from Catalog* pick a pre-priced catalog item; price is fixed, no surface-area calculation 1. *Product from Catalog* pick a pre-priced catalog item; price is fixed, no surface-area calculation
@@ -286,8 +297,9 @@ public static class HelpKnowledgeBase
2. Select customer 2. Select customer
3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo) 3. Add line items (same wizard as quotes: Calculated, Custom Work, or AI Photo)
4. Set priority, due date, assigned worker, special instructions 4. Set priority, due date, assigned worker, special instructions
5. Optionally set Oven & Batch Settings select a named oven, number of batches, and cycle time. These affect the oven cost in pricing. 5. Optionally enter a **Project Name** a short label (e.g. "Front Gate Panels") that appears on the job, linked invoice, and printed documents to help the customer identify what the work is for.
6. Save 6. Optionally set Oven & Batch Settings select a named oven, number of batches, and cycle time. These affect the oven cost in pricing.
7. Save
**Job Priority Board:** [/JobsPriority](/JobsPriority) Kanban-style view of all active jobs sorted by priority and status. **Job Priority Board:** [/JobsPriority](/JobsPriority) Kanban-style view of all active jobs sorted by priority and status.
@@ -328,6 +340,8 @@ public static class HelpKnowledgeBase
**Job Templates:** [/JobTemplates](/JobTemplates) Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items. **Job Templates:** [/JobTemplates](/JobTemplates) Save a job's items as a template to reuse for common work types. When creating a new job, select a template to pre-fill items.
**Cloning a Job:** On any Job Details page, click the **Clone Job** button (copy icon in the header toolbar). The system creates a new draft job immediately and redirects you to it. The clone copies: customer, description, PO number, project name, special instructions, tags, priority, discount %, oven settings, and all line items with their coats and prep services. It does NOT copy: due date, scheduled date, assigned worker, photos, notes, time entries, status history, or any linked invoice or payments. The clone starts in Pending status so it goes through the normal workflow.
**Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system. **Changing the customer on a job:** On the Job Details page, the Customer field is an always-visible dropdown. Select a different customer a confirmation banner appears. Click **Save** to apply or **Cancel** to revert. Use this to correct a misassigned job or to move a walk-in job to a customer's proper record after they've been added to the system.
**Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload. **Inline item price editing:** On the Job Details page, any unit price, quantity, or item description can be edited in-place without opening the full edit form. Click the value it becomes an input field. Type the new value, then press Enter or click away to save (Escape cancels). The pricing summary card (Items Subtotal, Subtotal, Tax, and Total) and the Job Costing card both update immediately without a page reload.
@@ -377,8 +391,10 @@ public static class HelpKnowledgeBase
- *Voided* cancelled invoice - *Voided* cancelled invoice
- *Written Off* uncollectable, written off - *Written Off* uncollectable, written off
**Project Name on invoices:** If the linked job had a Project Name set, it auto-fills on the invoice and appears on the printed PDF to help the customer identify the work.
**How to create an invoice:** **How to create an invoice:**
1. From the Job Details page "Create Invoice" (recommended pre-fills all items), OR 1. From the Job Details page "Create Invoice" (recommended pre-fills all items including Project Name), OR
2. Go to [Invoices](/Invoices) "New Invoice" and select a job 2. Go to [Invoices](/Invoices) "New Invoice" and select a job
**Recording a payment:** **Recording a payment:**
@@ -470,6 +486,8 @@ public static class HelpKnowledgeBase
- **Low Stock** (red) quantity is greater than zero but at or below the reorder point; time to reorder - **Low Stock** (red) quantity is greater than zero but at or below the reorder point; time to reorder
- **Out of Stock** (dark/black) quantity is zero; an alert banner appears on the Details page - **Out of Stock** (dark/black) quantity is zero; an alert banner appears on the Details page
**Low Stock stat card (clickable filter):** The "Low Stock" KPI card at the top of the Inventory page is clickable. Click it to instantly filter the list to only items needing reorder. Click it again (or clear the filter banner) to return to the full list. This is the fastest way to generate a reorder checklist.
**Stock Adjustment:** From Inventory Details, click "Stock Adjustment" to open the quick-adjust modal. Choose Add Stock, Remove Stock, or Set Exact, enter the quantity, select a reason (required), and optionally add notes. Every adjustment is automatically recorded as a transaction with the reason and notes included. **Stock Adjustment:** From Inventory Details, click "Stock Adjustment" to open the quick-adjust modal. Choose Add Stock, Remove Stock, or Set Exact, enter the quantity, select a reason (required), and optionally add notes. Every adjustment is automatically recorded as a transaction with the reason and notes included.
**Inventory transactions:** Every stock movement is recorded automatically Initial (item creation), Purchase (PO receipt), Adjustment (manual or edit), Job Usage (powder consumed on a job coat), Sale, Return, Waste, Transfer. Each record stores date, quantity delta, unit cost, and running balance after the change. **Inventory transactions:** Every stock movement is recorded automatically Initial (item creation), Purchase (PO receipt), Adjustment (manual or edit), Job Usage (powder consumed on a job coat), Sale, Return, Waste, Transfer. Each record stores date, quantity delta, unit cost, and running balance after the change.
@@ -31,8 +31,9 @@
<div class="row g-2 mb-4"> <div class="row g-2 mb-4">
@foreach (var item in new[] @foreach (var item in new[]
{ {
("Customers", "people", "Customers"), ("Customers", "people", "Customers"),
("Jobs", "tools", "Jobs"), ("CustomerContacts", "person-lines-fill", "Customer Contacts"),
("Jobs", "tools", "Jobs"),
("Quotes", "file-earmark-text", "Quotes"), ("Quotes", "file-earmark-text", "Quotes"),
("Invoices", "receipt", "Invoices"), ("Invoices", "receipt", "Invoices"),
("Inventory", "boxes", "Inventory Items"), ("Inventory", "boxes", "Inventory Items"),
@@ -312,7 +312,7 @@
</div> </div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" id="btnSaveCompanyInfo"> <button type="button" class="btn btn-primary" id="btnSaveCompanyInfo">
<i class="bi bi-save"></i> Save Changes <i class="bi bi-save"></i> Save Changes
</button> </button>
</div> </div>
@@ -2197,6 +2197,15 @@
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()"> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowWalkthrough()">
<i class="bi bi-question-circle me-1"></i>How it works <i class="bi bi-question-circle me-1"></i>How it works
</button> </button>
<a href="/CompanySettings/ExportCustomItemTemplates"
class="btn btn-outline-secondary btn-sm"
title="Download all templates as a JSON backup file">
<i class="bi bi-download me-1"></i>Export
</a>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="cfShowImport()"
title="Restore templates from a JSON backup file">
<i class="bi bi-upload me-1"></i>Import
</button>
<button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()"> <button type="button" class="btn btn-primary btn-sm" onclick="cfShowCreate()">
<i class="bi bi-plus-circle me-1"></i>New Template <i class="bi bi-plus-circle me-1"></i>New Template
</button> </button>
@@ -2281,6 +2290,40 @@
</div> </div>
</div> </div>
<!-- Custom Formula Import Modal -->
<div class="modal fade" id="cfImportModal" tabindex="-1" aria-labelledby="cfImportModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cfImportModalLabel">
<i class="bi bi-upload me-2"></i>Import Formula Templates
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p class="text-muted small mb-3">
Select a <code>.json</code> file previously exported from this page.
Templates whose name already exists in your account will be skipped.
</p>
<div class="mb-3">
<label class="form-label fw-semibold">Backup file <span class="text-danger">*</span></label>
<input type="file" id="cfImportFile" class="form-control" accept=".json" />
</div>
<div id="cfImportResults" class="d-none">
<hr />
<div id="cfImportSummary"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="cfImportBtn" onclick="cfSubmitImport()">
<i class="bi bi-upload me-1"></i>Import
</button>
</div>
</div>
</div>
</div>
<!-- Custom Formula Walkthrough Modal --> <!-- Custom Formula Walkthrough Modal -->
<div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true"> <div class="modal fade" id="cfWalkthroughModal" tabindex="-1" aria-labelledby="cfWalkthroughLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable"> <div class="modal-dialog modal-lg modal-dialog-centered modal-dialog-scrollable">
@@ -2636,6 +2679,34 @@
</div> </div>
</div> </div>
@if (ViewBag.IsDemoCompany == true)
{
<div class="container-fluid mt-4">
<div class="card border-warning">
<div class="card-header bg-warning bg-opacity-10 d-flex align-items-center gap-2">
<i class="bi bi-arrow-clockwise text-warning fs-5"></i>
<strong>Demo Environment</strong>
</div>
<div class="card-body">
<p class="mb-2">
This is the <strong>DEMO</strong> company. Use the button below to wipe and re-seed all
demo data with fresh dates. Workers and system configuration are preserved.
</p>
<p class="text-muted small mb-3">
Reset takes 10&ndash;30 seconds. You will be redirected here when complete.
</p>
<form asp-controller="Demo" asp-action="ResetDemoData" method="post"
onsubmit="this.querySelector('button').disabled=true; this.querySelector('button').innerHTML='<span class=\'spinner-border spinner-border-sm me-2\'></span>Resetting&hellip;';">
@Html.AntiForgeryToken()
<button type="submit" class="btn btn-warning">
<i class="bi bi-arrow-clockwise me-1"></i>Reset Demo Data
</button>
</form>
</div>
</div>
</div>
}
@section Scripts { @section Scripts {
<script> <script>
$(document).ready(function () { $(document).ready(function () {
@@ -2706,10 +2777,8 @@
} }
}); });
// Company Info Form Submit // Company Info Save
$('#companyInfoForm').on('submit', function (e) { $('#btnSaveCompanyInfo').on('click', function () {
e.preventDefault();
const formData = { const formData = {
CompanyName: $('#companyName').val(), CompanyName: $('#companyName').val(),
CompanyCode: $('#companyCode').val(), CompanyCode: $('#companyCode').val(),
@@ -3149,7 +3218,7 @@
// Button success animation helper // Button success animation helper
function showButtonSuccess(btn, originalHtml, duration = 2000) { function showButtonSuccess(btn, originalHtml, duration = 2000) {
btn.removeClass('btn-primary').addClass('btn-success'); btn.prop('disabled', false).removeClass('btn-primary').addClass('btn-success');
btn.html('<i class="bi bi-check-circle-fill"></i> Saved!'); btn.html('<i class="bi bi-check-circle-fill"></i> Saved!');
setTimeout(function() { setTimeout(function() {
btn.removeClass('btn-success').addClass('btn-primary'); btn.removeClass('btn-success').addClass('btn-primary');

Some files were not shown because too many files have changed in this diff Show More