Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 81dc34bab4 | |||
| b9e9449c8b | |||
| fd38785942 | |||
| 33277de727 | |||
| 4ac62551f4 | |||
| 7fa385aeb8 | |||
| 8452ea3fcd | |||
| 9b34ff564e | |||
| 24f3df1bbc | |||
| 551116d7e5 | |||
| 8768e9813b | |||
| 4a7087cc0c | |||
| 59b152c89f | |||
| 441898b52f | |||
| 3e30397302 | |||
| 31c5746e5b | |||
| 3f9ac27afa | |||
| df504674e9 | |||
| 07796b05c8 | |||
| 2bf8871892 | |||
| 8a0a564885 | |||
| dd4785b048 | |||
| e185e3b7e3 | |||
| 8acbc8605d | |||
| 485f0b69c8 | |||
| f380c152ca | |||
| 79c8c7e6a4 | |||
| 6cf355071b | |||
| ebd474ae81 | |||
| 3c390a2e05 | |||
| 0df2353d4f | |||
| be0a5b26e2 | |||
| 36680eced9 | |||
| 27aa4e0ea6 | |||
| b2d6fae400 | |||
| 3a1928f9bf | |||
| df9863a0bb | |||
| 6cefdff18c | |||
| 91a5dbe30c | |||
| b2a1b9a0be | |||
| 1a44133a63 | |||
| 7020797a25 | |||
| 3b5511a703 | |||
| 8df37ca760 | |||
| 7239f55308 | |||
| 09e077897b | |||
| 051c86810e | |||
| 6721de91e4 | |||
| 226a6237a6 | |||
| cf6acc125f | |||
| f467862877 | |||
| 7ad7d84016 | |||
| 75b0a8afe2 | |||
| 38748c2152 | |||
| 4ec55e7290 | |||
| 3eda91f170 | |||
| cefdf3e35c | |||
| f34ee749be | |||
| 357ef84001 | |||
| 7a1a697dc2 | |||
| 539c6c2559 | |||
| a947494cbd | |||
| 7e79a13cb1 | |||
| 2ad6df1195 | |||
| dc3cd75ea4 | |||
| a73f14fa7f | |||
| 0af31c39b3 | |||
| e1256503be | |||
| b69ff6db3a | |||
| 66231822af | |||
| d5ad9fa073 | |||
| d134dd51e5 | |||
| 1df7c13abd | |||
| 4a8778504f | |||
| f1d7054b3e | |||
| 46b950baf2 | |||
| 4e9c9d321a | |||
| 0c8723ef84 | |||
| 377bb1ce38 | |||
| 2acf54e1a9 | |||
| 0b24c320cd | |||
| 350f2d7658 | |||
| 856d202b78 | |||
| 8caaa84eac | |||
| e70f7ee9f1 | |||
| 6a918c2afc | |||
| 27bfd4db4d | |||
| 787d1504ef | |||
| 726bebdce9 | |||
| 786b78e502 | |||
| cb1b6dceb6 | |||
| fb31fa7eb3 | |||
| 637be701ea | |||
| e9cd67f5d9 | |||
| 433090effd | |||
| 4ca90f561e | |||
| f95397204c | |||
| 31d305b66a | |||
| 42a8c089d5 | |||
| 2c353f2e7f | |||
| c02a5584b4 | |||
| 17da692dce | |||
| 656f830898 | |||
| dde66c807f | |||
| feff0fa73d | |||
| 59beba2e15 | |||
| 959e323f3a | |||
| e2f9e9ae4f | |||
| 328b195127 | |||
| f6d457fe0e | |||
| c65445b94e | |||
| ccb094e57a | |||
| 0204430fa5 | |||
| 4fd9c52aaf | |||
| fde24b09c9 | |||
| a255893ada | |||
| d94612cc9c | |||
| 14026818e2 | |||
| 42eff3357e | |||
| d3a5d827f9 | |||
| 1229081436 | |||
| cf9dcfb4c1 | |||
| a33687f7bd | |||
| 0afb474c3e | |||
| 7e1676cfd7 | |||
| 379b0de885 | |||
| edd7389d7d | |||
| 61866e1d1e | |||
| bc9de38da3 | |||
| 2694863d07 | |||
| 8646fa83c8 | |||
| 796d084ea6 | |||
| 6d23c63912 | |||
| 3803d16731 | |||
| 29fd7163dc |
@@ -129,3 +129,7 @@ DataProtection-Keys/
|
||||
# Secrets
|
||||
appsettings.secrets.json
|
||||
*.pfx
|
||||
|
||||
# Local task tracking
|
||||
TODO.txt
|
||||
TODO.txt.bak
|
||||
|
||||
@@ -478,6 +478,27 @@ All modules below are fully implemented with controllers, views, and migrations
|
||||
- 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
|
||||
|
||||
`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.**
|
||||
|
||||
| Flag | Effect if missing on JobItem |
|
||||
|------|------------------------------|
|
||||
| `IsAiItem` | Job repriced as calculated item; oven cost double-charged on every save |
|
||||
| `IsGenericItem` | ManualUnitPrice ignored; price recalculated from surface area |
|
||||
| `IsLaborItem` | Item repriced at surface-area rate instead of hours × labor rate |
|
||||
| `IsSalesItem` | ManualUnitPrice ignored; item repriced using coat/surface math |
|
||||
|
||||
**Checklist when adding a new pricing routing flag:**
|
||||
1. Add the property to `QuoteItem` (Core/Entities)
|
||||
2. Add the property to `JobItem` (Core/Entities)
|
||||
3. Add it to `CreateQuoteItemDto` (Application/DTOs)
|
||||
4. Add it to `JobItemSeed` (private class in JobItemAssemblyService)
|
||||
5. Map it in all three `JobItemAssemblyService.CreateJobItem` overloads
|
||||
6. Include it in every `existingItemsData` JSON block in job views (`Edit.cshtml`, `EditItems.cshtml`) and in all job controller actions that build `CreateQuoteItemDto` from a `JobItem`
|
||||
7. Add a migration if the field is new on a persisted entity
|
||||
8. The structural test `PricingRoutingFlags_ExistOnBothQuoteItemAndJobItem` in `JobItemAssemblyServiceTests` will fail until steps 1–3 are done — this is 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
|
||||
|
||||
Vendored
+49
-1
@@ -24,6 +24,17 @@ pipeline {
|
||||
}
|
||||
}
|
||||
|
||||
stage('Test') {
|
||||
steps {
|
||||
bat 'dotnet test tests\\PowderCoating.UnitTests --no-build -c Release --logger "trx;LogFileName=results.trx" --results-directory TestResults'
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit testResults: 'TestResults/*.trx', allowEmptyResults: true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Run Migrations') {
|
||||
steps {
|
||||
bat 'dotnet tool install --global dotnet-ef 2>nul || dotnet tool update --global dotnet-ef 2>nul'
|
||||
@@ -42,7 +53,18 @@ pipeline {
|
||||
|
||||
stage('Deploy to Azure') {
|
||||
steps {
|
||||
bat 'powershell -Command "Add-Type -Assembly System.IO.Compression.FileSystem; if (Test-Path deploy.zip) { Remove-Item deploy.zip }; [System.IO.Compression.ZipFile]::CreateFromDirectory(\'publish\', \'deploy.zip\')"'
|
||||
powershell '''
|
||||
Add-Type -Assembly System.IO.Compression.FileSystem
|
||||
if (Test-Path deploy.zip) { Remove-Item deploy.zip }
|
||||
$publishDir = (Resolve-Path "publish").Path
|
||||
$zip = [System.IO.Compression.ZipFile]::Open("deploy.zip", "Create")
|
||||
Get-ChildItem -Path $publishDir -Recurse -File | ForEach-Object {
|
||||
$entryName = $_.FullName.Substring($publishDir.Length + 1).Replace("\\", "/")
|
||||
[System.IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, $_.FullName, $entryName, "Optimal") | Out-Null
|
||||
}
|
||||
$zip.Dispose()
|
||||
Write-Host "deploy.zip created with forward-slash entry paths"
|
||||
'''
|
||||
withCredentials([azureServicePrincipal(
|
||||
credentialsId: 'azure-pcl',
|
||||
subscriptionIdVariable: 'AZ_SUB_ID',
|
||||
@@ -57,6 +79,32 @@ pipeline {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Health Check') {
|
||||
steps {
|
||||
powershell '''
|
||||
$url = "https://app.powdercoatinglogix.com/"
|
||||
$timeout = 180
|
||||
$elapsed = 0
|
||||
Write-Host "Polling $url for up to $timeout seconds..."
|
||||
do {
|
||||
Start-Sleep -Seconds 10
|
||||
$elapsed += 10
|
||||
try {
|
||||
$r = Invoke-WebRequest $url -UseBasicParsing -TimeoutSec 10
|
||||
if ($r.StatusCode -lt 400) {
|
||||
Write-Host "App responded HTTP $($r.StatusCode) after ${elapsed}s"
|
||||
exit 0
|
||||
}
|
||||
} catch {
|
||||
Write-Host "[${elapsed}s] Not yet responding: $_"
|
||||
}
|
||||
} while ($elapsed -lt $timeout)
|
||||
Write-Error "App did not come healthy within $timeout seconds"
|
||||
exit 1
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
post {
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-Add feature to prep for events where we can generate coupons or gift certificates in bulk
|
||||
|
||||
Duplication refactor memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md.
|
||||
|
||||
Current memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md
|
||||
|
||||
|
||||
|
||||
-Google review request email after a job
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
-Fix up approve/decline messages between customer and user on quote approval feature
|
||||
|
||||
Done and need testing
|
||||
=====================
|
||||
-Add sorting to all grids
|
||||
-Add searching to all grids
|
||||
-Add Workers to the system
|
||||
-Allow jobs to be assigned to workers
|
||||
-Add Shop Job Board display to show in the shop
|
||||
-Added quick edits on a few pages
|
||||
-Fix job page customer drop down. It's only showing business names and not individuals
|
||||
-Add country drop down on customer edit and add pages
|
||||
-Conver customer once quote accepted not complete
|
||||
-Add Dashboard page
|
||||
-Low Inventory Warnings display
|
||||
-Overdue jobs
|
||||
-Todays Jobs
|
||||
-new quote button on customer page doesnt pre-select customer
|
||||
-Add customer job history page
|
||||
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
|
||||
-Date format can be customized per profile
|
||||
-Timezone can now be changed per profile
|
||||
-Have company logos stored in the database with the other company information
|
||||
-Add Company Name under Logo in navbar
|
||||
-Make logo bigger
|
||||
-Update create quote page to show names of individual customers or company name depending on which type it is
|
||||
-Validate that the company has entered operating costs before allowing the quote page to be loaded
|
||||
-Make phone number and contact required on quotes for new prospects
|
||||
-Move the create quote button to the right side of the screen to be consistent with other pages
|
||||
-Add setting for tax exempt on customer
|
||||
-Added tax certificate upload as well
|
||||
-Add shop minimum to quoting system and company settings
|
||||
-Add Rush Job Fee (customizable in company settings)
|
||||
-Add ability to quick change the status on the job listing and record who changed the status.
|
||||
-Deactivating company should NOT allow any users to login at all.
|
||||
-Allow superadmins to create company users/managers
|
||||
-Add a print quote button
|
||||
-Add a download PDF button for quotes
|
||||
-When adding users, also create worker records
|
||||
-Add quick update to all view pages
|
||||
-Add Mobile layouts
|
||||
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
|
||||
-Add ability to upload job photos
|
||||
-Allow photo uploads for jobs before and after photos
|
||||
-Added Log Viewer
|
||||
-Added Seed Data option for super admins that will assist during testing
|
||||
-Add an item list with prices for repeat parts and such
|
||||
-Add manual data seeding that super admins can use to seed a company one at a time if needed
|
||||
-Add Log Viewer for Super Admins
|
||||
-Quotes cleaned up quite a bit and calculations and style changed
|
||||
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
|
||||
-Job Items now appear on the Job Screen with the line items from the quote
|
||||
-Job items can be edited
|
||||
-Add a way to convert a quote into a job
|
||||
-Add multiple item types to add to a quote
|
||||
1. Pre-Defined item that we can choose from our product list
|
||||
2. Batch items where we enter the square footage manually as well as the quantity
|
||||
-Add Quickbooks import for customers and price lists (Desktop and Online)
|
||||
-Custom Order Powder not saving or displaying properly on quuote page
|
||||
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
|
||||
-Add Randomizer Wheel
|
||||
-Add Quickbooks format export for
|
||||
-Customers
|
||||
-Product Catalog
|
||||
-Invoices
|
||||
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
|
||||
-Add a Shop Supplies operating cost that will be used on quote calculations
|
||||
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
|
||||
-Update everywhere that uses tax rate to read and use this setting
|
||||
-Add ability to export a full price list for known items
|
||||
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
|
||||
-Update the inventory screen to not duplicate color name fields and the like
|
||||
-Add option for metric system
|
||||
-Add Bulk Upload for
|
||||
-Powder
|
||||
-Product Catalog
|
||||
-Customer Data
|
||||
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
|
||||
-Allow shops to put employee days off on the calendar as well
|
||||
-Fix and Verify user permissions are honored
|
||||
-Run a full security check on the application
|
||||
-Add support for multi stage coatings on an item
|
||||
-Fix Seed Data routines to track errors better and continue past error imports
|
||||
-Add ability to complete a job and enter actual time and materials used
|
||||
-Add export for all data to CSV format
|
||||
-Check calendar resizing with the browser. It's off a bit
|
||||
-Add ability to apply discounts
|
||||
-Remove powder from inventory when completeing a job
|
||||
-Add color change ability for appointment types
|
||||
-Add code to honor the rush charge on a quote
|
||||
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
|
||||
-Add ability to add sq ft to product catalog item for powder estimation
|
||||
-Add better UX design for validation errors and such
|
||||
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
|
||||
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
|
||||
Option 2: Add inline validation (more complex)
|
||||
- Show error messages right next to the problematic field
|
||||
- Better UX but requires adding validation spans to dynamic fields
|
||||
|
||||
Option 3: Toast notifications (requires new library/code)
|
||||
- Modern popup notifications for success/error messages
|
||||
- Would need to add a toast library (like Toastr) and wire it up
|
||||
-Add Import/Export for Company Settings
|
||||
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
|
||||
-Allow recurring scheduled maintenance
|
||||
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
|
||||
-Make sure maintenence shows on the calendar list view.
|
||||
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
|
||||
-Add support for multiple ovens in operating costs
|
||||
-Display oven selected on quote and job detail pages
|
||||
-Allow user to choose an oven on a quote, and have it follow through to a job
|
||||
-Check for any old and outdated code and DB fields!
|
||||
-Add ability to email a quote
|
||||
-Add email capabilities
|
||||
-Add search on super admin companies screen
|
||||
-Set limits on job photos per app tier
|
||||
-Check subscription signup page to make sure the selected subscription is actually saved.
|
||||
-Don't seed the product catalog on a new user
|
||||
-Check to make sure subscription page has quotes and all fields on it
|
||||
-Allow customizing of the quote sheets and invoices (If we do them)
|
||||
-Add feature to allow username changes
|
||||
-Fix quickbooks imports based on files Colton sent
|
||||
-Add thicker border around input fields to signify they are text boxes
|
||||
-Check to make sure emails get sent when a quote is created
|
||||
-Add buttons to send emails manually if needed
|
||||
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
|
||||
-Add ability to modify items on jobs
|
||||
-Swap quoting page to use modals to add items to segregate it a bit better.
|
||||
-Build account ledger/transaction summary view
|
||||
-Add security for financial pages
|
||||
-Allow opening balances for accounts
|
||||
-Create P&L and other reports
|
||||
-Allow receipet upload on expenses and bills
|
||||
-Download PDF for invoices throws and error
|
||||
-Emailing invoice doesn't seem to trigger
|
||||
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
|
||||
-When doing anything that sends mail, prompt the user to alert them a message will be sent
|
||||
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
|
||||
-Check Workflow steps in wizard, might need adjusting
|
||||
-Account Summary, use permanent alert for info message at bottom
|
||||
-Add steps so that the new user can customize the data lookups and re-order them
|
||||
-Reorder menu to work better
|
||||
-Add ability to print a job invoice once completed
|
||||
-Add ability to email a job invoice
|
||||
-Integrate invoicing/billing/reports
|
||||
-Add customer portal to approve quotes from a link for now. We can do a full login later.
|
||||
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
|
||||
-Add tagging options for quotes and jobs (user driven)
|
||||
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
|
||||
description of WHY the user should add some tags though.
|
||||
-Inventory forecasting might be worth looking into
|
||||
-Build some AI powder usage predictions into the system
|
||||
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
|
||||
-Update dashboard to show some $$$ fields
|
||||
-Update Setup Wizard
|
||||
-Update the Setup Checklist
|
||||
-Modify system to keep running balances of all accounts
|
||||
- Make sure ALL job updates refresh the Shop Display
|
||||
-Add multiple item types to add to a quote
|
||||
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
|
||||
-Integration with stripe or square to accept online paymens from our users customers.
|
||||
-AI Assistant for help
|
||||
-Allow customer filtering on quotes and jobs
|
||||
-New job page blanks when validation fails
|
||||
-Can we keep track of which users have completed the setup wizard?
|
||||
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
|
||||
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
|
||||
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
|
||||
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
|
||||
-Add images to product catalog items for easily identification of parts
|
||||
-Look into possibly having AI scan a product catalog and suggest prices for items.
|
||||
-Add Oven and Add Blasting Setup don't work in Setup Wizard
|
||||
-When scanning inventory QR Code, there is no cancel button
|
||||
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
|
||||
-Add SMS capabilities
|
||||
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
|
||||
|
||||
5/7/2026
|
||||
-When editing a job/quote item from catalog, pre-select the item chosen please
|
||||
-Move buttons to right side of job details page
|
||||
-When completing a job, pull in powder usage already entered
|
||||
-Fix invoice due date to match terms selected
|
||||
-Invoice Status should not show on PDF unless PAID
|
||||
-If we start with a job, shop supplies is not being added to the items
|
||||
-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it
|
||||
-Customer approval page doesn't show all charges (Oven time missing?)
|
||||
-Time Logging default user to logged in user
|
||||
-Add Print Invoice button or allow viewing the PDF
|
||||
-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one.
|
||||
-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created
|
||||
-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes)
|
||||
-Support entering multiple email addresses (comma seperated) in each field
|
||||
-If no email on file, then prompt for address to send to.
|
||||
-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list?
|
||||
-When choosing a prospect for a quote, we need way to consent and enable SMS for them
|
||||
|
||||
Ideas Removed
|
||||
=======================
|
||||
-Add Deactivate Customer button on Customer Detail page
|
||||
|
||||
|
||||
Logins:
|
||||
rich@r2r.com/Ragz2Richs123!
|
||||
|
||||
rich@cannon.com/Cannon123!
|
||||
-226
@@ -1,226 +0,0 @@
|
||||
Shop Management App TO DO List
|
||||
==============================
|
||||
-When editing a job/quote item from catalog, pre-select the item chosen please
|
||||
-Move buttons to right side of job details page
|
||||
-When completing a job, pull in powder usage already entered
|
||||
-Fix invoice due date to match terms selected
|
||||
-Invoice Status should not show on PDF unless PAID
|
||||
-If we start with a job, shop supplies is not being added to the items
|
||||
-If you delete an invoice attached to a job, the create invoice button keeps trying to go back to it
|
||||
-Customer approval page doesn't show all charges (Oven time missing?)
|
||||
-Time Logging default user to logged in user
|
||||
-Add Print Invoice button or allow viewing the PDF
|
||||
-If an invoice is voided, I cant create a new one from a job. Show voided invoice as history, but allow creating a new one.
|
||||
-If a completed job is changed after an invoice is created, we need to update the invoice. Also need to be able to modify an invoice to add a discount or similar after it's created
|
||||
-Add multiple email address for commercial customers (Accounting for invoices and contact for quotes)
|
||||
-Support entering multiple email addresses (comma seperated) in each field
|
||||
-If no email on file, then prompt for address to send to.
|
||||
-When choosing a powder NOT in stock, can we incorporate our inventory lookup function to find a powder, link it to the quote, add it to the inventory with a 0lb balance and still put it on the "powder to order" list?
|
||||
-When choosing a prospect for a quote, we need way to consent and enable SMS for them
|
||||
|
||||
|
||||
|
||||
|
||||
Duplication refactor memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-refactor-plan-2026-05-07.md.
|
||||
|
||||
Current memory
|
||||
C:/Users/spoul/.codex/memories/powdercoatingapp-quote-sync-extracted-2026-05-07.md
|
||||
|
||||
|
||||
|
||||
-Google review request email after a job
|
||||
-Check my ChatGPT chat about surface area for a few solid ideas for the system
|
||||
-Fix up approve/decline messages between customer and user on quote approval feature
|
||||
|
||||
Done and need testing
|
||||
=====================
|
||||
-Add sorting to all grids
|
||||
-Add searching to all grids
|
||||
-Add Workers to the system
|
||||
-Allow jobs to be assigned to workers
|
||||
-Add Shop Job Board display to show in the shop
|
||||
-Added quick edits on a few pages
|
||||
-Fix job page customer drop down. It's only showing business names and not individuals
|
||||
-Add country drop down on customer edit and add pages
|
||||
-Conver customer once quote accepted not complete
|
||||
-Add Dashboard page
|
||||
-Low Inventory Warnings display
|
||||
-Overdue jobs
|
||||
-Todays Jobs
|
||||
-new quote button on customer page doesnt pre-select customer
|
||||
-Add customer job history page
|
||||
-Profiles can now change from a light theme to a dark theme as well as other appearance changes
|
||||
-Date format can be customized per profile
|
||||
-Timezone can now be changed per profile
|
||||
-Have company logos stored in the database with the other company information
|
||||
-Add Company Name under Logo in navbar
|
||||
-Make logo bigger
|
||||
-Update create quote page to show names of individual customers or company name depending on which type it is
|
||||
-Validate that the company has entered operating costs before allowing the quote page to be loaded
|
||||
-Make phone number and contact required on quotes for new prospects
|
||||
-Move the create quote button to the right side of the screen to be consistent with other pages
|
||||
-Add setting for tax exempt on customer
|
||||
-Added tax certificate upload as well
|
||||
-Add shop minimum to quoting system and company settings
|
||||
-Add Rush Job Fee (customizable in company settings)
|
||||
-Add ability to quick change the status on the job listing and record who changed the status.
|
||||
-Deactivating company should NOT allow any users to login at all.
|
||||
-Allow superadmins to create company users/managers
|
||||
-Add a print quote button
|
||||
-Add a download PDF button for quotes
|
||||
-When adding users, also create worker records
|
||||
-Add quick update to all view pages
|
||||
-Add Mobile layouts
|
||||
-Fix a few text pieces on the dashboard page that did not invert properly when dark mode was selected
|
||||
-Add ability to upload job photos
|
||||
-Allow photo uploads for jobs before and after photos
|
||||
-Added Log Viewer
|
||||
-Added Seed Data option for super admins that will assist during testing
|
||||
-Add an item list with prices for repeat parts and such
|
||||
-Add manual data seeding that super admins can use to seed a company one at a time if needed
|
||||
-Add Log Viewer for Super Admins
|
||||
-Quotes cleaned up quite a bit and calculations and style changed
|
||||
-Approving a Quote will now auto-create a Job and link back to the quote it came from.
|
||||
-Job Items now appear on the Job Screen with the line items from the quote
|
||||
-Job items can be edited
|
||||
-Add a way to convert a quote into a job
|
||||
-Add multiple item types to add to a quote
|
||||
1. Pre-Defined item that we can choose from our product list
|
||||
2. Batch items where we enter the square footage manually as well as the quantity
|
||||
-Add Quickbooks import for customers and price lists (Desktop and Online)
|
||||
-Custom Order Powder not saving or displaying properly on quuote page
|
||||
-Added ability for Companies to define their own Job Status, Job Priority, and Quote Status' via Company Settings > Data Lookups
|
||||
-Add Randomizer Wheel
|
||||
-Add Quickbooks format export for
|
||||
-Customers
|
||||
-Product Catalog
|
||||
-Invoices
|
||||
-Quote for Product Catalog Item is only selecting items from Powder Coating, need all items
|
||||
-Add a Shop Supplies operating cost that will be used on quote calculations
|
||||
-Fix Quote screen, only Powder showing in item dropdown. Need to get all items in an IsCoating category showing up.
|
||||
-Update everywhere that uses tax rate to read and use this setting
|
||||
-Add ability to export a full price list for known items
|
||||
-Add tracking for all changes and show change history on view page. Possibly in a hidden grid or modal
|
||||
-Update the inventory screen to not duplicate color name fields and the like
|
||||
-Add option for metric system
|
||||
-Add Bulk Upload for
|
||||
-Powder
|
||||
-Product Catalog
|
||||
-Customer Data
|
||||
-Add an Appointment engine and Calendar. Also show Maintenence tasks that are scheduled on it
|
||||
-Allow shops to put employee days off on the calendar as well
|
||||
-Fix and Verify user permissions are honored
|
||||
-Run a full security check on the application
|
||||
-Add support for multi stage coatings on an item
|
||||
-Fix Seed Data routines to track errors better and continue past error imports
|
||||
-Add ability to complete a job and enter actual time and materials used
|
||||
-Add export for all data to CSV format
|
||||
-Check calendar resizing with the browser. It's off a bit
|
||||
-Add ability to apply discounts
|
||||
-Remove powder from inventory when completeing a job
|
||||
-Add color change ability for appointment types
|
||||
-Add code to honor the rush charge on a quote
|
||||
-Add options to quote for Sandblasting, Masking, Chemical Strip, Outgas, Phosphate Wash, Degrease
|
||||
-Add ability to add sq ft to product catalog item for powder estimation
|
||||
-Add better UX design for validation errors and such
|
||||
Option 1: Change "ModelOnly" to "All" (1 line change) - Shows all validation errors at top of form in red alert box
|
||||
- User would have seen: "The field Estimated Minutes must be between 0 and 10,000"
|
||||
Option 2: Add inline validation (more complex)
|
||||
- Show error messages right next to the problematic field
|
||||
- Better UX but requires adding validation spans to dynamic fields
|
||||
|
||||
Option 3: Toast notifications (requires new library/code)
|
||||
- Modern popup notifications for success/error messages
|
||||
- Would need to add a toast library (like Toastr) and wire it up
|
||||
-Add Import/Export for Company Settings
|
||||
-Allow Super Admin to modify permissions for company admins in case we add any in the future, or if anything gets messed up we can fix it!
|
||||
-Allow recurring scheduled maintenance
|
||||
-Let's show scheduled maintenence on the job schedule as well. At the top of the screen
|
||||
-Make sure maintenence shows on the calendar list view.
|
||||
-Add viewing quotes on the customer details page so we can see all quotes/jobs for a given customer to make things easier to find.
|
||||
-Add support for multiple ovens in operating costs
|
||||
-Display oven selected on quote and job detail pages
|
||||
-Allow user to choose an oven on a quote, and have it follow through to a job
|
||||
-Check for any old and outdated code and DB fields!
|
||||
-Add ability to email a quote
|
||||
-Add email capabilities
|
||||
-Add search on super admin companies screen
|
||||
-Set limits on job photos per app tier
|
||||
-Check subscription signup page to make sure the selected subscription is actually saved.
|
||||
-Don't seed the product catalog on a new user
|
||||
-Check to make sure subscription page has quotes and all fields on it
|
||||
-Allow customizing of the quote sheets and invoices (If we do them)
|
||||
-Add feature to allow username changes
|
||||
-Fix quickbooks imports based on files Colton sent
|
||||
-Add thicker border around input fields to signify they are text boxes
|
||||
-Check to make sure emails get sent when a quote is created
|
||||
-Add buttons to send emails manually if needed
|
||||
-Modify price calculations to prompt for service times (ie... sandblasting, oven cure times, outgas times etc)
|
||||
-Add ability to modify items on jobs
|
||||
-Swap quoting page to use modals to add items to segregate it a bit better.
|
||||
-Build account ledger/transaction summary view
|
||||
-Add security for financial pages
|
||||
-Allow opening balances for accounts
|
||||
-Create P&L and other reports
|
||||
-Allow receipet upload on expenses and bills
|
||||
-Download PDF for invoices throws and error
|
||||
-Emailing invoice doesn't seem to trigger
|
||||
-When a customer record has email notifications turned off, disable any email buttons that may send one and alert the user that this customer is set to have notifications turned off.
|
||||
-When doing anything that sends mail, prompt the user to alert them a message will be sent
|
||||
-Create a setup wizard for new users that will walk through system setup. Allow re-running later.
|
||||
-Check Workflow steps in wizard, might need adjusting
|
||||
-Account Summary, use permanent alert for info message at bottom
|
||||
-Add steps so that the new user can customize the data lookups and re-order them
|
||||
-Reorder menu to work better
|
||||
-Add ability to print a job invoice once completed
|
||||
-Add ability to email a job invoice
|
||||
-Integrate invoicing/billing/reports
|
||||
-Add customer portal to approve quotes from a link for now. We can do a full login later.
|
||||
-Need a complexity score for quoting parts (Simple, moderate, complex, extreme)
|
||||
-Add tagging options for quotes and jobs (user driven)
|
||||
-Can we also add this tag system to quotes and jobs to allow users to tag themselves and we can use that data later as well? We'd have to add a good
|
||||
description of WHY the user should add some tags though.
|
||||
-Inventory forecasting might be worth looking into
|
||||
-Build some AI powder usage predictions into the system
|
||||
-AI Production Scheduling - Batching enough parts together to fill the oven automagically
|
||||
-Update dashboard to show some $$$ fields
|
||||
-Update Setup Wizard
|
||||
-Update the Setup Checklist
|
||||
-Modify system to keep running balances of all accounts
|
||||
- Make sure ALL job updates refresh the Shop Display
|
||||
-Add multiple item types to add to a quote
|
||||
AI Agent item where we upload a picture and it will calculate the approximate sq ft and quote from that
|
||||
-Integration with stripe or square to accept online paymens from our users customers.
|
||||
-AI Assistant for help
|
||||
-Allow customer filtering on quotes and jobs
|
||||
-New job page blanks when validation fails
|
||||
-Can we keep track of which users have completed the setup wizard?
|
||||
-Make sure we're tracking logins. I see a user logged on, but the company health page states they have never logged in.
|
||||
-Allow printing blank work orders (model after the SCP Powder Coating blank work order)
|
||||
-IDEA: Print powders to use on work order with their QR code so they can be scanned right from there and usage recorded.
|
||||
-Add ability to save a quoted item to the product catalog either from an AI Photo Quote or from the calculated item
|
||||
-Add images to product catalog items for easily identification of parts
|
||||
-Look into possibly having AI scan a product catalog and suggest prices for items.
|
||||
-Add Oven and Add Blasting Setup don't work in Setup Wizard
|
||||
-When scanning inventory QR Code, there is no cancel button
|
||||
-Bug: When scanning Inventory QR Code, if not logged in...it takes you to the dashboard after login, not our inventory scanning screen
|
||||
-Add SMS capabilities
|
||||
-Lookup not working 100% correct. If I type columbia as the manufacturer and a color name....it's finding blackmamba from prismatic incorrectly.
|
||||
-Lookup Modal not showing ALL matches. Maybe make scrollable
|
||||
-Pickup cure information from TDS Sheet if not found by AI Search
|
||||
-ON AI Photo Quote page, when the AI info comes back we should scroll the modal window down so it's visible. It's not clear that new info has been added to the modal for all customers
|
||||
-Inventory Lookup not always finding price for Columbia Coatings
|
||||
-Logging powder usage and choosing a job doesn't record properly in the activity section of the powder itself
|
||||
-Need to allow deleting of powder usage entries, or at least editing in case of a goof up
|
||||
-Still random weird characters on a bunch of pages. Intake button for example on the jobs screen shows: Intake ✓
|
||||
|
||||
Ideas Removed
|
||||
=======================
|
||||
-Add Deactivate Customer button on Customer Detail page
|
||||
|
||||
|
||||
Logins:
|
||||
rich@r2r.com/Ragz2Richs123!
|
||||
|
||||
rich@cannon.com/Cannon123!
|
||||
@@ -322,3 +322,214 @@ public class ClaudeAnomalyFlag
|
||||
public string? RecommendedAction { get; set; }
|
||||
public string? BillNumber { get; set; }
|
||||
}
|
||||
|
||||
// ── Feature 7: Bank Rec Auto-Match ───────────────────────────────────────────
|
||||
|
||||
public class BankRecMatchItem
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty; // "Payment", "BillPayment", "Expense"
|
||||
public int EntityId { get; set; }
|
||||
public string Date { get; set; } = string.Empty; // ISO 8601
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string Direction { get; set; } = string.Empty; // "deposit" or "payment"
|
||||
}
|
||||
|
||||
public class AutoMatchRequest
|
||||
{
|
||||
public List<BankRecMatchItem> UnclearedItems { get; set; } = new();
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal StatementEndingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class AutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; } // 0.0–1.0
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class AutoMatchResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<AutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for bank rec auto-match.</summary>
|
||||
public class ClaudeAutoMatchResponse
|
||||
{
|
||||
public List<ClaudeAutoMatchSuggestion> SuggestedCleared { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeAutoMatchSuggestion
|
||||
{
|
||||
public string EntityType { get; set; } = string.Empty;
|
||||
public int EntityId { get; set; }
|
||||
public double Confidence { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 8: Late Payment Prediction ───────────────────────────────────────
|
||||
|
||||
public class OpenInvoiceSummary
|
||||
{
|
||||
public string InvoiceNumber { get; set; } = string.Empty;
|
||||
public decimal BalanceDue { get; set; }
|
||||
public string? DueDateIso { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
public class LatePaymentCustomerData
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public decimal TotalOwed { get; set; }
|
||||
public double AvgDaysToPay { get; set; } // historical average
|
||||
public int TotalInvoicesAllTime { get; set; }
|
||||
public int LateInvoicesAllTime { get; set; }
|
||||
public List<OpenInvoiceSummary> OpenInvoices { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<LatePaymentCustomerData> Customers { get; set; } = new();
|
||||
}
|
||||
|
||||
public class LatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class LatePaymentPredictionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<LatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for late payment predictions.</summary>
|
||||
public class ClaudeLatePaymentResponse
|
||||
{
|
||||
public List<ClaudeLatePaymentPrediction> Predictions { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeLatePaymentPrediction
|
||||
{
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string RiskLevel { get; set; } = "medium";
|
||||
public int EstimatedDaysToPayment { get; set; }
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ── Feature 9: Natural Language Financial Queries ─────────────────────────────
|
||||
|
||||
public class MonthlyFinancialSummary
|
||||
{
|
||||
public string Month { get; set; } = string.Empty; // "YYYY-MM"
|
||||
public decimal Revenue { get; set; }
|
||||
public decimal Expenses { get; set; }
|
||||
public decimal NetIncome { get; set; }
|
||||
}
|
||||
|
||||
public class FinancialQueryContext
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string AsOfDate { get; set; } = string.Empty;
|
||||
public decimal TotalRevenueYtd { get; set; }
|
||||
public decimal TotalExpensesYtd { get; set; }
|
||||
public decimal NetIncomeYtd { get; set; }
|
||||
public decimal ArOutstanding { get; set; }
|
||||
public decimal ApOutstanding { get; set; }
|
||||
public List<MonthlyFinancialSummary> Last12Months { get; set; } = new();
|
||||
public List<ExpenseByCategory> ExpensesByCategory { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryRequest
|
||||
{
|
||||
public string Question { get; set; } = string.Empty;
|
||||
public FinancialQueryContext Context { get; set; } = new();
|
||||
}
|
||||
|
||||
public class FinancialQueryResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for financial queries.</summary>
|
||||
public class ClaudeFinancialQueryResponse
|
||||
{
|
||||
public string Answer { get; set; } = string.Empty;
|
||||
public string? FollowUpSuggestion { get; set; }
|
||||
public List<string> RelevantFacts { get; set; } = new();
|
||||
}
|
||||
|
||||
// ── Feature 10: Recurring Bill Detection ─────────────────────────────────────
|
||||
|
||||
public class RecurringBillHistoryItem
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
public string DateIso { get; set; } = string.Empty;
|
||||
public string? Memo { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionRequest
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<RecurringBillHistoryItem> Bills { get; set; } = new();
|
||||
}
|
||||
|
||||
public class RecurringBillPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
/// <summary>"monthly", "quarterly", "biannual", "annual"</summary>
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
/// <summary>"high", "medium", or "low"</summary>
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
public class RecurringBillDetectionResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string? ErrorMessage { get; set; }
|
||||
public List<RecurringBillPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Internal JSON schema that Claude returns for recurring bill detection.</summary>
|
||||
public class ClaudeRecurringBillResponse
|
||||
{
|
||||
public List<ClaudeRecurringPattern> Patterns { get; set; } = new();
|
||||
public List<string> Insights { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ClaudeRecurringPattern
|
||||
{
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string Frequency { get; set; } = string.Empty;
|
||||
public decimal TypicalAmount { get; set; }
|
||||
public string? NextExpectedDateIso { get; set; }
|
||||
public string Confidence { get; set; } = "medium";
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public string? SuggestedAction { get; set; }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,158 @@ using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Accounting;
|
||||
|
||||
// Accounting method badge — set on report DTOs so views can show "Cash Basis" / "Accrual Basis"
|
||||
// without needing a separate round-trip to the company settings.
|
||||
|
||||
|
||||
// ── Cash Flow Statement ──────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Cash Flow Statement using the direct (cash-basis) method for operating activities.
|
||||
/// Investing and Financing sections contain line items derived from account-level changes.
|
||||
/// BeginningCash + NetChangeInCash should equal EndingCash (within rounding tolerances).
|
||||
/// </summary>
|
||||
public class CashFlowStatementDto
|
||||
{
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public AccountingMethod Method { get; set; }
|
||||
|
||||
// ── Operating (direct / cash method) ───────────────────────────────────
|
||||
/// <summary>Customer invoice payments received in the period.</summary>
|
||||
public decimal CashFromCustomers { get; set; }
|
||||
/// <summary>Vendor bill payments made in the period.</summary>
|
||||
public decimal CashToVendors { get; set; }
|
||||
/// <summary>Direct expense payments made in the period (not via bills).</summary>
|
||||
public decimal CashForExpenses { get; set; }
|
||||
public decimal NetOperating => CashFromCustomers - CashToVendors - CashForExpenses;
|
||||
|
||||
// ── Investing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> InvestingLines { get; set; } = new();
|
||||
public decimal NetInvesting => InvestingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Financing ──────────────────────────────────────────────────────────
|
||||
public List<CashFlowLineDto> FinancingLines { get; set; } = new();
|
||||
public decimal NetFinancing => FinancingLines.Sum(l => l.Amount);
|
||||
|
||||
// ── Summary ────────────────────────────────────────────────────────────
|
||||
public decimal BeginningCash { get; set; }
|
||||
public decimal NetChangeInCash => NetOperating + NetInvesting + NetFinancing;
|
||||
public decimal EndingCash => BeginningCash + NetChangeInCash;
|
||||
}
|
||||
|
||||
/// <summary>A single line in the Investing or Financing section of the Cash Flow Statement.</summary>
|
||||
public class CashFlowLineDto
|
||||
{
|
||||
public string Label { get; set; } = string.Empty;
|
||||
/// <summary>Positive = cash inflow, negative = cash outflow.</summary>
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
// ── Customer / Vendor Statements ─────────────────────────────────────────────
|
||||
|
||||
public class CustomerStatementDto
|
||||
{
|
||||
public int CustomerId { get; set; }
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public string? CustomerAddress { get; set; }
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class VendorStatementDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public decimal OpeningBalance { get; set; }
|
||||
public List<StatementLineDto> Lines { get; set; } = new();
|
||||
public decimal ClosingBalance { get; set; }
|
||||
}
|
||||
|
||||
public class StatementLineDto
|
||||
{
|
||||
public DateTime Date { get; set; }
|
||||
/// <summary>E.g., "Invoice", "Payment", "Credit Applied", "Deposit Applied".</summary>
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Reference { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
/// <summary>Amount added to the balance (invoice for customer, bill for vendor).</summary>
|
||||
public decimal? Debit { get; set; }
|
||||
/// <summary>Amount reducing the balance (payment, credit).</summary>
|
||||
public decimal? Credit { get; set; }
|
||||
public decimal RunningBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── AP Aging ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public class ApAgingReportDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
public List<ApAgingVendorDto> Vendors { get; set; } = new();
|
||||
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalOutstanding => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingVendorDto
|
||||
{
|
||||
public int VendorId { get; set; }
|
||||
public string VendorName { get; set; } = string.Empty;
|
||||
public List<ApAgingBillDto> Bills { get; set; } = new();
|
||||
public decimal TotalCurrent { get; set; }
|
||||
public decimal Total1to30 { get; set; }
|
||||
public decimal Total31to60 { get; set; }
|
||||
public decimal Total61to90 { get; set; }
|
||||
public decimal TotalOver90 { get; set; }
|
||||
public decimal TotalBalance => TotalCurrent + Total1to30 + Total31to60 + Total61to90 + TotalOver90;
|
||||
}
|
||||
|
||||
public class ApAgingBillDto
|
||||
{
|
||||
public int BillId { get; set; }
|
||||
public string BillNumber { get; set; } = string.Empty;
|
||||
public DateTime BillDate { get; set; }
|
||||
public DateTime? DueDate { get; set; }
|
||||
public decimal BalanceDue { get; set; }
|
||||
public int DaysOverdue { get; set; }
|
||||
}
|
||||
|
||||
// ── Trial Balance ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class TrialBalanceDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public List<TrialBalanceLine> Lines { get; set; } = new();
|
||||
public decimal TotalDebits { get; set; }
|
||||
public decimal TotalCredits { get; set; }
|
||||
public bool IsBalanced => Math.Abs(TotalDebits - TotalCredits) < 0.01m;
|
||||
}
|
||||
|
||||
public class TrialBalanceLine
|
||||
{
|
||||
public int AccountId { get; set; }
|
||||
public string AccountNumber { get; set; } = string.Empty;
|
||||
public string AccountName { get; set; } = string.Empty;
|
||||
public AccountType AccountType { get; set; }
|
||||
public decimal DebitBalance { get; set; }
|
||||
public decimal CreditBalance { get; set; }
|
||||
}
|
||||
|
||||
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||
|
||||
public class ProfitAndLossDto
|
||||
@@ -9,6 +161,7 @@ public class ProfitAndLossDto
|
||||
public DateTime From { get; set; }
|
||||
public DateTime To { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
public List<FinancialReportLine> RevenueLines { get; set; } = new();
|
||||
public decimal TotalRevenue { get; set; }
|
||||
@@ -40,6 +193,7 @@ public class BalanceSheetDto
|
||||
{
|
||||
public DateTime AsOf { get; set; }
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
// Assets
|
||||
public List<FinancialReportLine> CurrentAssets { get; set; } = new();
|
||||
|
||||
@@ -3,6 +3,22 @@ namespace PowderCoating.Application.DTOs.Common;
|
||||
public class PagedResult<T>
|
||||
{
|
||||
public IEnumerable<T> Items { get; set; } = new List<T>();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a PagedResult populated from a GridRequest, avoiding repetitive property
|
||||
/// assignments across every Index action. SortColumn, SortDirection, and SearchTerm
|
||||
/// are copied from the grid so the model carries full state for view binding.
|
||||
/// </summary>
|
||||
public static PagedResult<T> From(GridRequest grid, IEnumerable<T> items, int totalCount) => new()
|
||||
{
|
||||
Items = items,
|
||||
PageNumber = grid.PageNumber,
|
||||
PageSize = grid.PageSize,
|
||||
TotalCount = totalCount,
|
||||
SortColumn = grid.SortColumn,
|
||||
SortDirection = grid.SortDirection,
|
||||
SearchTerm = grid.SearchTerm
|
||||
};
|
||||
public int PageNumber { get; set; }
|
||||
public int PageSize { get; set; }
|
||||
public int TotalCount { get; set; }
|
||||
|
||||
@@ -71,6 +71,11 @@ public class CompanyListDto
|
||||
public bool WizardCompleted { get; set; }
|
||||
public DateTime? WizardCompletedAt { get; set; }
|
||||
public string? WizardCompletedByName { get; set; }
|
||||
|
||||
// Health signals — populated by CompaniesController.Index after the count summary query
|
||||
public int HealthScore { get; set; }
|
||||
public string HealthRisk { get; set; } = "Healthy";
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -59,6 +59,9 @@ public class CompanyPreferencesDto
|
||||
// Blank Work Order PDF Template
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
public string? WoTerms { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
public class UpdateAppDefaultsDto
|
||||
@@ -136,3 +139,11 @@ public class UpdateWorkOrderTemplateDto
|
||||
public string WoAccentColor { get; set; } = "#374151";
|
||||
[StringLength(2000)] public string? WoTerms { get; set; }
|
||||
}
|
||||
|
||||
|
||||
public class UpdateKioskSettingsDto
|
||||
{
|
||||
/// <summary>"Quote" (default) or "Job" — what the kiosk creates on submission.</summary>
|
||||
[Required]
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
public string? State { get; set; }
|
||||
public string? ZipCode { get; set; }
|
||||
public string? TimeZone { get; set; }
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
public bool HasLogo { get; set; }
|
||||
|
||||
public CompanyOperatingCostsDto? OperatingCosts { get; set; }
|
||||
@@ -96,6 +97,9 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
[StringLength(50, ErrorMessage = "Time zone cannot exceed 50 characters")]
|
||||
public string? TimeZone { get; set; }
|
||||
|
||||
/// <summary>Cash or Accrual accounting method preference for financial reports.</summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -108,6 +112,7 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
|
||||
// Labor Rates
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
public decimal AdditionalCoatLaborPercent { get; set; }
|
||||
|
||||
// Equipment Operating Costs
|
||||
@@ -181,6 +186,10 @@ namespace PowderCoating.Application.DTOs.Company
|
||||
[Display(Name = "Standard Labor Rate ($/hr)")]
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
|
||||
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||
[Display(Name = "Shop Labor Cost Rate ($/hr)")]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
[Range(0, 100, ErrorMessage = "Additional coat labor percent must be between 0 and 100")]
|
||||
[Display(Name = "Additional Coat Labor (%)")]
|
||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||
|
||||
@@ -140,12 +140,12 @@ public class CreateCustomerDto : IValidatableObject
|
||||
new[] { nameof(CompanyName), nameof(ContactFirstName), nameof(ContactLastName) });
|
||||
}
|
||||
|
||||
// At least one contact method is required (Email OR Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone))
|
||||
// At least one contact method is required (Email, Phone, or Mobile Phone)
|
||||
if (string.IsNullOrWhiteSpace(Email) && string.IsNullOrWhiteSpace(Phone) && string.IsNullOrWhiteSpace(MobilePhone))
|
||||
{
|
||||
yield return new ValidationResult(
|
||||
"Please provide at least one contact method (Email or Phone)",
|
||||
new[] { nameof(Email), nameof(Phone) });
|
||||
"Please provide at least one contact method (Email, Phone, or Mobile Phone)",
|
||||
new[] { nameof(Email), nameof(Phone), nameof(MobilePhone) });
|
||||
}
|
||||
|
||||
// Validate each address in comma-separated email fields
|
||||
|
||||
@@ -16,6 +16,7 @@ public class GiftCertificateListDto
|
||||
public GiftCertificateStatus Status { get; set; }
|
||||
public DateTime IssueDate { get; set; }
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
public Guid? BatchId { get; set; }
|
||||
}
|
||||
|
||||
public class GiftCertificateDto : GiftCertificateListDto
|
||||
@@ -87,3 +88,27 @@ public class RedeemGiftCertificateDto
|
||||
[Range(0.01, 9999.99)]
|
||||
public decimal Amount { get; set; }
|
||||
}
|
||||
|
||||
public class BulkCreateGiftCertificateDto
|
||||
{
|
||||
[Required]
|
||||
[Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
|
||||
[Display(Name = "Number of Certificates")]
|
||||
public int Quantity { get; set; } = 25;
|
||||
|
||||
[Required]
|
||||
[Range(1.00, 9999.99, ErrorMessage = "Amount must be between $1.00 and $9,999.99.")]
|
||||
[Display(Name = "Face Value (each)")]
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
[Required]
|
||||
[Display(Name = "Issued Reason")]
|
||||
public GiftCertificateIssuedReason IssuedReason { get; set; } = GiftCertificateIssuedReason.Promotional;
|
||||
|
||||
[Display(Name = "Expiry Date (optional)")]
|
||||
public DateTime? ExpiryDate { get; set; }
|
||||
|
||||
[StringLength(1000)]
|
||||
[Display(Name = "Event / Notes (applied to all certificates)")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
using CsvHelper.Configuration.Attributes;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Import;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for importing shop workers from CSV files.
|
||||
/// Valid Role values: GeneralLabor, Sandblaster, Coater, Masker, QualityControl, OvenOperator, Supervisor, Maintenance
|
||||
/// </summary>
|
||||
public class ShopWorkerImportDto
|
||||
{
|
||||
[Name("Name")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Name("Role")]
|
||||
public string Role { get; set; } = "GeneralLabor";
|
||||
|
||||
[Name("Phone")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[Name("Email")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
[Name("IsActive")]
|
||||
public bool? IsActive { get; set; }
|
||||
|
||||
[Name("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -32,7 +32,9 @@ public class InvoiceDto
|
||||
public string CustomerName { get; set; } = string.Empty;
|
||||
public string? CustomerEmail { get; set; }
|
||||
public string? CustomerPhone { get; set; }
|
||||
public string? CustomerMobilePhone { get; set; }
|
||||
public bool CustomerNotifyByEmail { get; set; }
|
||||
public bool CustomerNotifyBySms { get; set; }
|
||||
public string? PreparedById { get; set; }
|
||||
public string? PreparedByName { get; set; }
|
||||
public InvoiceStatus Status { get; set; }
|
||||
@@ -82,6 +84,10 @@ public class CreateInvoiceDto
|
||||
public string? InternalNotes { get; set; }
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
/// <summary>Early-payment discount percentage parsed from the customer's payment terms (e.g., 2.0 for "2/10 Net 30"). Informational — does not auto-apply.</summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
/// <summary>Number of days within which the early-payment discount applies (e.g., 10 for "2/10 Net 30").</summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
public List<CreateInvoiceItemDto> InvoiceItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ public class IssueRefundDto
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime RefundDate { get; set; } = DateTime.Today;
|
||||
public PaymentMethod RefundMethod { get; set; }
|
||||
/// <summary>Bank/cash account money leaves when issuing a cash/card refund. Null for store credit.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
public string Reason { get; set; } = string.Empty;
|
||||
public string? Reference { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
@@ -137,6 +137,13 @@ public class CreateJobDto
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -208,6 +215,16 @@ public class UpdateJobDto
|
||||
[Display(Name = "Assigned Worker")]
|
||||
public string? AssignedUserId { get; set; }
|
||||
|
||||
[Display(Name = "Oven")]
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
[Display(Name = "Batches")]
|
||||
[Range(1, 999)]
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
|
||||
[Display(Name = "Cycle Time (min)")]
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Description is required")]
|
||||
[StringLength(2000, ErrorMessage = "Description cannot exceed 2000 characters")]
|
||||
[Display(Name = "Description")]
|
||||
@@ -381,6 +398,7 @@ public class JobItemCoatDto
|
||||
public decimal? PowderCostPerLb { get; set; }
|
||||
public decimal? PowderToOrder { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; } // Filled during job completion
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
@@ -389,7 +407,7 @@ public class CompleteJobDto
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public decimal? ActualTimeSpentHours { get; set; }
|
||||
public List<JobItemCoatUsageDto> CoatUsages { get; set; } = new();
|
||||
public List<JobPowderUsageDto> PowderUsages { get; set; } = new();
|
||||
public bool SendEmailToCustomer { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -400,10 +418,10 @@ public class SendJobSmsRequest
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// DTO for tracking actual powder usage per coat
|
||||
public class JobItemCoatUsageDto
|
||||
// DTO for tracking actual powder usage per inventory item (color) for the whole job
|
||||
public class JobPowderUsageDto
|
||||
{
|
||||
public int JobItemCoatId { get; set; }
|
||||
public int InventoryItemId { get; set; }
|
||||
public decimal? ActualPowderUsedLbs { get; set; }
|
||||
}
|
||||
|
||||
@@ -515,6 +533,9 @@ public class JobEditItemsViewModel
|
||||
public string JobNumber { get; set; } = string.Empty;
|
||||
public int? CustomerId { get; set; }
|
||||
public decimal TaxPercent { get; set; }
|
||||
public int? OvenCostId { get; set; }
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
public List<CreateQuoteItemDto> JobItems { get; set; } = new();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.Kiosk;
|
||||
|
||||
// ── Staff-facing ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Input for sending a remote intake link to a customer by email.</summary>
|
||||
public class SendRemoteLinkDto
|
||||
{
|
||||
[Required, EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional — used to personalise the email greeting.</summary>
|
||||
public string? CustomerName { get; set; }
|
||||
}
|
||||
|
||||
// ── Customer-facing step DTOs ─────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Step 1 — Contact information submitted by the customer.</summary>
|
||||
public class SubmitKioskContactDto
|
||||
{
|
||||
[Required, MaxLength(100)]
|
||||
public string FirstName { get; set; } = string.Empty;
|
||||
|
||||
[Required, MaxLength(100)]
|
||||
public string LastName { get; set; } = string.Empty;
|
||||
|
||||
[Required, Phone]
|
||||
public string Phone { get; set; } = string.Empty;
|
||||
|
||||
[Required, EmailAddress]
|
||||
public string Email { get; set; } = string.Empty;
|
||||
|
||||
public bool IsReturningCustomer { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Step 2 — Job description submitted by the customer.</summary>
|
||||
public class SubmitKioskJobDto
|
||||
{
|
||||
[Required, MaxLength(2000)]
|
||||
public string JobDescription { get; set; } = string.Empty;
|
||||
|
||||
public string? HowDidYouHearAboutUs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Step 3 — Terms agreement (+ optional drawn signature for in-person sessions).</summary>
|
||||
public class SubmitKioskTermsDto
|
||||
{
|
||||
[Required]
|
||||
[Range(typeof(bool), "true", "true", ErrorMessage = "You must agree to the terms to continue.")]
|
||||
public bool AgreedToTerms { get; set; }
|
||||
|
||||
public bool SmsOptIn { get; set; }
|
||||
|
||||
/// <summary>Base-64 PNG from signature_pad; required for InPerson sessions, null for Remote.</summary>
|
||||
public string? SignatureDataBase64 { get; set; }
|
||||
}
|
||||
|
||||
// ── Staff review list ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>One row in the Kiosk Intakes staff review list.</summary>
|
||||
public class KioskSessionListDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public Guid SessionToken { get; set; }
|
||||
public KioskSessionType SessionType { get; set; }
|
||||
public KioskSessionStatus Status { get; set; }
|
||||
public string CustomerFirstName { get; set; } = string.Empty;
|
||||
public string CustomerLastName { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
public string JobDescription { get; set; } = string.Empty;
|
||||
public bool SmsOptIn { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
public int? LinkedJobId { get; set; }
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
|
||||
public string CustomerFullName => $"{CustomerFirstName} {CustomerLastName}".Trim();
|
||||
public string JobDescriptionSnippet =>
|
||||
JobDescription.Length > 80 ? JobDescription[..80] + "…" : JobDescription;
|
||||
public bool IsConverted => LinkedJobId.HasValue || LinkedQuoteId.HasValue;
|
||||
public bool IsExpired => Status == KioskSessionStatus.Expired ||
|
||||
(Status == KioskSessionStatus.Active && DateTime.UtcNow > ExpiresAt);
|
||||
}
|
||||
@@ -604,6 +604,11 @@ public class QuotePricingBreakdownDto
|
||||
|
||||
public decimal SubtotalBeforeDiscount { get; set; }
|
||||
|
||||
public decimal PricingTierDiscountAmount { get; set; }
|
||||
public decimal PricingTierDiscountPercent { get; set; }
|
||||
public decimal QuoteDiscountAmount { get; set; }
|
||||
public decimal QuoteDiscountPercent { get; set; }
|
||||
|
||||
public decimal DiscountAmount { get; set; }
|
||||
public decimal DiscountPercent { get; set; }
|
||||
|
||||
@@ -796,6 +801,7 @@ public class QuoteItemCoatDto
|
||||
public decimal CoatMaterialCost { get; set; }
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class CreateShopWorkerDto
|
||||
{
|
||||
[Required(ErrorMessage = "Worker name is required")]
|
||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Role is required")]
|
||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
||||
|
||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class ShopWorkerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool IsActive { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.DTOs.ShopWorker;
|
||||
|
||||
public class UpdateShopWorkerDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Worker name is required")]
|
||||
[StringLength(100, ErrorMessage = "Name cannot exceed 100 characters")]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Required(ErrorMessage = "Role is required")]
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
|
||||
[Phone(ErrorMessage = "Invalid phone number format")]
|
||||
[StringLength(20, ErrorMessage = "Phone cannot exceed 20 characters")]
|
||||
public string? Phone { get; set; }
|
||||
|
||||
[EmailAddress(ErrorMessage = "Invalid email address format")]
|
||||
[StringLength(100, ErrorMessage = "Email cannot exceed 100 characters")]
|
||||
public string? Email { get; set; }
|
||||
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[StringLength(500, ErrorMessage = "Notes cannot exceed 500 characters")]
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
@@ -41,6 +41,8 @@ public class CompanyUserDto
|
||||
public bool CanManageMaintenance { get; set; }
|
||||
public bool CanManageInvoices { get; set; }
|
||||
public bool CanViewReports { get; set; }
|
||||
public bool CanManageBills { get; set; }
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -156,6 +158,12 @@ public class CreateCompanyUserDto
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
|
||||
[Display(Name = "Send Welcome Email")]
|
||||
public bool SendWelcomeEmail { get; set; } = true;
|
||||
}
|
||||
@@ -209,6 +217,10 @@ public class UpdateCompanyUserDto
|
||||
[Display(Name = "Active")]
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[Range(0, 10000, ErrorMessage = "Labor cost rate must be between 0 and 10,000")]
|
||||
[Display(Name = "Labor Cost Rate ($/hr)")]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
[Required(ErrorMessage = "Hire date is required")]
|
||||
[Display(Name = "Hire Date")]
|
||||
public DateTime HireDate { get; set; }
|
||||
@@ -258,4 +270,10 @@ public class UpdateCompanyUserDto
|
||||
|
||||
[Display(Name = "Can View Reports")]
|
||||
public bool CanViewReports { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Bills & AP")]
|
||||
public bool CanManageBills { get; set; }
|
||||
|
||||
[Display(Name = "Can Manage Accounting")]
|
||||
public bool CanManageAccounting { get; set; }
|
||||
}
|
||||
|
||||
@@ -120,6 +120,9 @@ public class CreateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; } = false;
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
@@ -201,6 +204,9 @@ public class UpdateVendorDto
|
||||
[Display(Name = "Preferred Vendor")]
|
||||
public bool IsPreferred { get; set; }
|
||||
|
||||
[Display(Name = "1099 Vendor")]
|
||||
public bool Is1099Vendor { get; set; }
|
||||
|
||||
[Display(Name = "Default Expense Account")]
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
}
|
||||
|
||||
@@ -43,4 +43,33 @@ public interface IAccountingAiService
|
||||
/// Returns a ranked list of flagged items with recommended actions.
|
||||
/// </summary>
|
||||
Task<AnomalyDetectionResult> DetectAnomaliesAsync(AnomalyDetectionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Suggests which uncleared bank rec items should be marked as cleared to reconcile
|
||||
/// a statement. Returns a ranked list of suggestions with confidence scores based on
|
||||
/// amount/date patterns and the gap between the current cleared balance and the
|
||||
/// statement ending balance.
|
||||
/// </summary>
|
||||
Task<AutoMatchResult> AutoMatchReconciliationAsync(AutoMatchRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Predicts likelihood of late payment for each open AR customer using their historical
|
||||
/// payment behavior (avg days to pay, late rate) combined with current overdue status.
|
||||
/// Returns risk levels (high/medium/low) and estimated days to collection.
|
||||
/// </summary>
|
||||
Task<LatePaymentPredictionResult> PredictLatePaymentsAsync(LatePaymentPredictionRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Answers a plain-English financial question (e.g. "What did we spend on powder last quarter?")
|
||||
/// using pre-loaded company financial context. Returns a direct answer, supporting facts,
|
||||
/// and an optional follow-up question suggestion.
|
||||
/// </summary>
|
||||
Task<FinancialQueryResult> AnswerFinancialQueryAsync(FinancialQueryRequest request);
|
||||
|
||||
/// <summary>
|
||||
/// Analyzes 6–12 months of bill history to detect recurring payment patterns per vendor.
|
||||
/// Returns detected patterns with frequency, typical amount, next expected date, and
|
||||
/// suggested actions (e.g. set a reminder, create a template).
|
||||
/// </summary>
|
||||
Task<RecurringBillDetectionResult> DetectRecurringBillsAsync(RecurringBillDetectionRequest request);
|
||||
}
|
||||
|
||||
@@ -136,17 +136,6 @@ public interface ICsvImportService
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportVendorsAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a CSV template file for shop worker imports.
|
||||
/// </summary>
|
||||
byte[] GenerateShopWorkerTemplate();
|
||||
|
||||
/// <summary>
|
||||
/// Import shop workers from a CSV stream.
|
||||
/// Updates existing workers matched by Name; creates new ones otherwise.
|
||||
/// </summary>
|
||||
Task<CsvImportResultDto> ImportShopWorkersAsync(Stream csvStream, int companyId);
|
||||
|
||||
/// <summary>
|
||||
/// Generate a CSV template file for prep service imports.
|
||||
/// </summary>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using PowderCoating.Application.DTOs.Accounting;
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
@@ -6,14 +7,16 @@ namespace PowderCoating.Application.Interfaces;
|
||||
/// Read-only service for financial aggregate reports. All methods query the database
|
||||
/// with AsNoTracking and return pre-shaped DTOs — no tracked entities are returned.
|
||||
/// Implemented in Infrastructure; uses ApplicationDbContext directly.
|
||||
/// The <paramref name="method"/> parameter overrides the company's stored preference when
|
||||
/// supplied; pass <c>null</c> to fall back to the company's configured accounting method.
|
||||
/// </summary>
|
||||
public interface IFinancialReportService
|
||||
{
|
||||
/// <summary>Returns a Profit & Loss report for the given company and date range.</summary>
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to);
|
||||
Task<ProfitAndLossDto> GetProfitAndLossAsync(int companyId, DateTime from, DateTime to, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns a Balance Sheet snapshot as of the given date.</summary>
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf);
|
||||
Task<BalanceSheetDto> GetBalanceSheetAsync(int companyId, DateTime asOf, AccountingMethod? method = null);
|
||||
|
||||
/// <summary>Returns an AR Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days.</summary>
|
||||
Task<ArAgingReportDto> GetArAgingAsync(int companyId, DateTime asOf);
|
||||
@@ -23,4 +26,27 @@ public interface IFinancialReportService
|
||||
|
||||
/// <summary>Returns an invoice-basis Sales Tax Liability report for the given company and date range.</summary>
|
||||
Task<SalesTaxReportDto> GetSalesTaxReportAsync(int companyId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns an AP Aging report bucketed at 0-30, 31-60, 61-90, and 90+ days past the bill due date.</summary>
|
||||
Task<ApAgingReportDto> GetApAgingAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
||||
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
||||
|
||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a customer showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<CustomerStatementDto> GetCustomerStatementAsync(int companyId, int customerId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>Returns a dated activity statement for a vendor showing opening balance, all transactions in the period, and closing balance.</summary>
|
||||
Task<VendorStatementDto> GetVendorStatementAsync(int companyId, int vendorId, DateTime from, DateTime to);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a Cash Flow Statement for the period using the direct (cash-basis) method for
|
||||
/// operating activities. Investing and Financing sections are derived from account-level data.
|
||||
/// BeginningCash is computed from all cash/bank account credits and debits prior to
|
||||
/// <paramref name="from"/>; EndingCash adds the net change during the period.
|
||||
/// </summary>
|
||||
Task<CashFlowStatementDto> GetCashFlowStatementAsync(int companyId, DateTime from, DateTime to);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IJobItemAssemblyService
|
||||
{
|
||||
JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
|
||||
JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public interface INotificationService
|
||||
/// Notify customer when an invoice has been sent.
|
||||
/// Optionally includes an online payment link in the email body.
|
||||
/// </summary>
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null);
|
||||
Task NotifyInvoiceSentAsync(Invoice invoice, byte[]? pdfAttachment = null, string? pdfFilename = null, string? paymentUrl = null, string? overrideEmail = null, bool sendSms = false, string? viewUrl = null);
|
||||
|
||||
/// <summary>
|
||||
/// Notify customer (internal) when a payment has been recorded on an invoice.
|
||||
@@ -91,4 +91,11 @@ public interface INotificationService
|
||||
/// Alert company staff when a Stripe chargeback (dispute) is opened on an invoice payment.
|
||||
/// </summary>
|
||||
Task NotifyChargebackAlertAsync(Invoice invoice, string disputeId, decimal amount, string reason);
|
||||
|
||||
/// <summary>
|
||||
/// Sends an appointment reminder email to the linked customer (if opted in) and writes a
|
||||
/// notification log row. Called by <see cref="PowderCoating.Web.BackgroundServices.AppointmentReminderBackgroundService"/>
|
||||
/// when the reminder window opens. In-app bell notification is handled by the caller.
|
||||
/// </summary>
|
||||
Task NotifyAppointmentReminderAsync(Appointment appointment);
|
||||
}
|
||||
|
||||
@@ -42,10 +42,19 @@ public interface IPdfService
|
||||
Task<byte[]> GenerateArAgingPdfAsync(ArAgingReportDto dto);
|
||||
Task<byte[]> GenerateSalesAndIncomePdfAsync(SalesIncomeReportDto dto);
|
||||
Task<byte[]> GenerateSalesTaxReportPdfAsync(SalesTaxReportDto dto);
|
||||
Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto);
|
||||
Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto);
|
||||
Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto);
|
||||
|
||||
Task<byte[]> GenerateGiftCertificatePdfAsync(
|
||||
GiftCertificateDto cert,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
|
||||
Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Interfaces;
|
||||
|
||||
public interface IQuotePricingAssemblyService
|
||||
{
|
||||
void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult);
|
||||
|
||||
Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc);
|
||||
}
|
||||
@@ -20,7 +20,6 @@ public interface IStripeConnectService
|
||||
decimal invoiceTotal,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string invoiceNumber,
|
||||
int invoiceId);
|
||||
|
||||
@@ -33,7 +32,6 @@ public interface IStripeConnectService
|
||||
decimal depositAmount,
|
||||
decimal surchargeAmount,
|
||||
string currency,
|
||||
string customerEmail,
|
||||
string quoteNumber,
|
||||
int quoteId);
|
||||
}
|
||||
|
||||
@@ -54,5 +54,6 @@ public class CompanyProfile : Profile
|
||||
CreateMap<UpdateQuoteTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateInvoiceTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateWorkOrderTemplateDto, CompanyPreferences>();
|
||||
CreateMap<UpdateKioskSettingsDto, CompanyPreferences>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ public class InvoiceProfile : Profile
|
||||
? (s.Customer.BillingEmail ?? s.Customer.Email)
|
||||
: null))
|
||||
.ForMember(d => d.CustomerPhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.Phone : null))
|
||||
.ForMember(d => d.CustomerMobilePhone, o => o.MapFrom(s => s.Customer != null ? s.Customer.MobilePhone : null))
|
||||
.ForMember(d => d.CustomerNotifyByEmail, o => o.MapFrom(s => s.Customer == null || s.Customer.NotifyByEmail))
|
||||
.ForMember(d => d.CustomerNotifyBySms, o => o.MapFrom(s => s.Customer != null && s.Customer.NotifyBySms))
|
||||
.ForMember(d => d.PreparedByName, o => o.MapFrom(s => s.PreparedBy != null
|
||||
? $"{s.PreparedBy.FirstName} {s.PreparedBy.LastName}".Trim()
|
||||
: null))
|
||||
|
||||
@@ -73,7 +73,7 @@ public class JobProfile : Profile
|
||||
// JobTimeEntry → JobTimeEntryDto
|
||||
CreateMap<JobTimeEntry, JobTimeEntryDto>()
|
||||
.ForMember(dest => dest.WorkerName, opt => opt.MapFrom(src =>
|
||||
src.UserDisplayName ?? (src.Worker != null ? src.Worker.Name : string.Empty)));
|
||||
src.UserDisplayName ?? string.Empty));
|
||||
|
||||
// CreateJobDto to Job
|
||||
CreateMap<CreateJobDto, Job>()
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
using AutoMapper;
|
||||
using PowderCoating.Application.DTOs.ShopWorker;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Mappings;
|
||||
|
||||
public class ShopWorkerProfile : Profile
|
||||
{
|
||||
public ShopWorkerProfile()
|
||||
{
|
||||
// Entity to DTO
|
||||
CreateMap<ShopWorker, ShopWorkerDto>();
|
||||
|
||||
// DTO to Entity
|
||||
CreateMap<CreateShopWorkerDto, ShopWorker>();
|
||||
CreateMap<UpdateShopWorkerDto, ShopWorker>();
|
||||
|
||||
// Reverse mappings
|
||||
CreateMap<ShopWorkerDto, ShopWorker>();
|
||||
CreateMap<ShopWorker, CreateShopWorkerDto>();
|
||||
CreateMap<ShopWorker, UpdateShopWorkerDto>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Shared file validation and content-type resolution used across all blob storage services.
|
||||
/// </summary>
|
||||
public static class BlobFileHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates an uploaded file against an extension allowlist and a maximum size.
|
||||
/// Returns the normalized (lowercase) extension on success so callers do not re-derive it.
|
||||
/// </summary>
|
||||
public static (bool IsValid, string Extension, string Error) ValidateUpload(
|
||||
IFormFile? file,
|
||||
string[] allowedExtensions,
|
||||
long maxBytes)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > maxBytes)
|
||||
return (false, string.Empty, $"File exceeds the {maxBytes / 1024 / 1024} MB limit.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !allowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed: {string.Join(", ", allowedExtensions)}.");
|
||||
|
||||
return (true, extension, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a file extension to its MIME content type, covering common image formats and
|
||||
/// document types. Falls back to <c>application/octet-stream</c>.
|
||||
/// </summary>
|
||||
public static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Strips OS-invalid filename characters from a base filename (no extension), replacing
|
||||
/// them with underscores to produce a safe blob path segment.
|
||||
/// </summary>
|
||||
public static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var sanitized = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
return string.IsNullOrWhiteSpace(sanitized) ? "file" : sanitized;
|
||||
}
|
||||
}
|
||||
@@ -47,15 +47,9 @@ public class CatalogImageService : ICatalogImageService
|
||||
string? existingImagePath,
|
||||
string? existingThumbnailPath)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed. Accepted types: jpg, jpeg, png, gif, webp.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var container = _settings.Containers.CatalogImages;
|
||||
var blobId = Guid.NewGuid().ToString("N");
|
||||
|
||||
@@ -67,21 +67,15 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveCompanyLogoAsync(IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old logo (any extension) before saving new one
|
||||
await DeleteOldLogosAsync(companyId, extension);
|
||||
|
||||
var blobName = GetCompanyLogoPath(companyId, extension);
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.CompanyLogos, blobName, stream, contentType);
|
||||
@@ -158,20 +152,4 @@ public class CompanyLogoService : ICompanyLogoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// The correct content type is required so that browsers render the image
|
||||
/// inline rather than triggering a download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,25 +56,16 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
/// </returns>
|
||||
public async Task<(bool Success, string FilePath, string ErrorMessage)> SaveEquipmentManualAsync(IFormFile file, int companyId, int equipmentId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file provided");
|
||||
|
||||
if (file.Length > MaxFileSize)
|
||||
return (false, string.Empty, $"File size exceeds maximum allowed size of {MaxFileSize / 1024 / 1024} MB");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(extension))
|
||||
return (false, string.Empty, $"File type not allowed. Allowed types: {string.Join(", ", AllowedExtensions)}");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Sanitize filename — replace OS-invalid characters with underscores to
|
||||
// prevent path traversal and blob naming errors in Azure.
|
||||
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
|
||||
fileName = string.Join("_", fileName.Split(Path.GetInvalidFileNameChars()));
|
||||
if (string.IsNullOrWhiteSpace(fileName))
|
||||
fileName = "manual";
|
||||
var fileName = BlobFileHelper.SanitizeFileName(Path.GetFileNameWithoutExtension(file.FileName));
|
||||
|
||||
var blobName = $"{companyId}/equipment-manuals/{equipmentId}/{fileName}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.Manuals, blobName, stream, contentType);
|
||||
@@ -130,19 +121,4 @@ public class EquipmentManualService : IEquipmentManualService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.Manuals, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Correct MIME types are required so browsers open PDFs inline and
|
||||
/// Word documents prompt a compatible application rather than a raw download.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts quote/job data into persisted <see cref="JobItem"/>, <see cref="JobItemCoat"/>,
|
||||
/// and <see cref="JobItemPrepService"/> entities.
|
||||
///
|
||||
/// Three source types are supported, each with a matching overload:
|
||||
/// 1. <see cref="CreateQuoteItemDto"/> — quote wizard (new job from form data + fresh pricing result)
|
||||
/// 2. <see cref="QuoteItem"/> — quote-to-job conversion (copies a saved quote line)
|
||||
/// 3. <see cref="JobItem"/> — job duplication / template instantiation (copies an existing job line)
|
||||
///
|
||||
/// The private <see cref="JobItemSeed"/> / <see cref="JobItemCoatSeed"/> / <see cref="JobItemPrepServiceSeed"/>
|
||||
/// intermediary classes exist solely to give all three overload paths a single <see cref="BuildJobItem"/>
|
||||
/// construction site — avoiding subtle copy-paste drift where one overload forgets to copy a new field.
|
||||
/// </summary>
|
||||
public class JobItemAssemblyService : IJobItemAssemblyService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> from a quote wizard DTO and a pre-calculated pricing result.
|
||||
/// Used when creating a job directly from the job form or from an approved quote via the wizard.
|
||||
/// Pricing is passed in separately because it was already computed upstream (CalculateQuoteItemPriceAsync).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(CreateQuoteItemDto source, int jobId, int companyId, QuoteItemPricingResult pricing, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(pricing);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = pricing.UnitPrice,
|
||||
TotalPrice = pricing.TotalPrice,
|
||||
LaborCost = pricing.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from the coat DTOs in the quote wizard form.
|
||||
/// PowderToOrder is recalculated server-side here (not trusted from the form) using surface area,
|
||||
/// quantity, coverage, and transfer efficiency — the wizard's displayed value is for UI only.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemPrepService"/> records (sandblasting, masking, etc.) from the
|
||||
/// quote wizard DTO. These are per-item prep steps with individual time estimates that feed
|
||||
/// labor cost calculations and shop floor instructions.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(CreateQuoteItemDto source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="JobItem"/> by copying a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Prices are taken directly from the quote snapshot — no repricing occurs — so the job starts with
|
||||
/// exactly the amounts that were approved by the customer.
|
||||
/// The first coat's color/finish is promoted to the job item's top-level fields for quick display
|
||||
/// (details remain in the coat records).
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(QuoteItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
var firstCoat = source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.FirstOrDefault();
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = firstCoat?.ColorName,
|
||||
ColorCode = firstCoat?.ColorCode,
|
||||
Finish = firstCoat?.Finish,
|
||||
SurfaceArea = source.SurfaceAreaSqFt,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.ItemLaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="JobItemCoat"/> records from a saved <see cref="QuoteItem"/> during quote-to-job conversion.
|
||||
/// Coat appearance (color name, code, finish) is resolved from the linked <see cref="InventoryItem"/> if available,
|
||||
/// because the inventory record is the canonical source of truth for a product's appearance —
|
||||
/// the values typed into the quote form may be incomplete or informal.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c =>
|
||||
{
|
||||
var appearance = ResolveCoatAppearance(c.ColorName, c.ColorCode, c.Finish, c.InventoryItem);
|
||||
return BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = appearance.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = appearance.ColorCode,
|
||||
Finish = appearance.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = CalculatePowderToOrder(c.PowderToOrder, source.SurfaceAreaSqFt, source.Quantity, c.CoverageSqFtPerLb, c.TransferEfficiency),
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies prep service records from a <see cref="QuoteItem"/> to a new job item during quote-to-job conversion.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(QuoteItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new <see cref="JobItem"/> by cloning an existing one — used for job templates
|
||||
/// and rework duplication where an existing job line is reused on a new job.
|
||||
/// Prices are copied as-is from the source; the job controller is responsible for repricing
|
||||
/// if operating costs have changed since the original job was created.
|
||||
/// </summary>
|
||||
public JobItem CreateJobItem(JobItem source, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return BuildJobItem(
|
||||
new JobItemSeed
|
||||
{
|
||||
Description = source.Description,
|
||||
Quantity = source.Quantity,
|
||||
ColorName = source.ColorName,
|
||||
ColorCode = source.ColorCode,
|
||||
Finish = source.Finish,
|
||||
SurfaceArea = source.SurfaceArea,
|
||||
SurfaceAreaSqFt = source.SurfaceAreaSqFt,
|
||||
CatalogItemId = source.CatalogItemId,
|
||||
IsGenericItem = source.IsGenericItem,
|
||||
IsLaborItem = source.IsLaborItem,
|
||||
IsSalesItem = source.IsSalesItem,
|
||||
IsAiItem = source.IsAiItem,
|
||||
Sku = source.Sku,
|
||||
ManualUnitPrice = source.ManualUnitPrice,
|
||||
PowderCostOverride = source.PowderCostOverride,
|
||||
UnitPrice = source.UnitPrice,
|
||||
TotalPrice = source.TotalPrice,
|
||||
LaborCost = source.LaborCost,
|
||||
RequiresSandblasting = source.RequiresSandblasting,
|
||||
RequiresMasking = source.RequiresMasking,
|
||||
EstimatedMinutes = source.EstimatedMinutes,
|
||||
Notes = source.Notes,
|
||||
IncludePrepCost = source.IncludePrepCost,
|
||||
Complexity = source.Complexity,
|
||||
AiTags = source.AiTags,
|
||||
AiPredictionId = source.AiPredictionId
|
||||
},
|
||||
jobId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones coat records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// PowderToOrder is copied verbatim (not recalculated) because the original job's powder
|
||||
/// quantities may have been manually adjusted after initial calculation.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemCoat> CreateJobItemCoats(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
|
||||
return source.Coats?
|
||||
.OrderBy(c => c.Sequence)
|
||||
.Select(c => BuildJobItemCoat(
|
||||
new JobItemCoatSeed
|
||||
{
|
||||
CoatName = c.CoatName,
|
||||
Sequence = c.Sequence,
|
||||
InventoryItemId = c.InventoryItemId,
|
||||
ColorName = c.ColorName,
|
||||
VendorId = c.VendorId,
|
||||
ColorCode = c.ColorCode,
|
||||
Finish = c.Finish,
|
||||
CoverageSqFtPerLb = c.CoverageSqFtPerLb,
|
||||
TransferEfficiency = c.TransferEfficiency,
|
||||
PowderCostPerLb = c.PowderCostPerLb,
|
||||
PowderToOrder = c.PowderToOrder,
|
||||
Notes = c.Notes,
|
||||
NoExtraLayerCharge = c.NoExtraLayerCharge
|
||||
},
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc))
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clones prep service records from an existing <see cref="JobItem"/> onto a new job item.
|
||||
/// </summary>
|
||||
public IReadOnlyList<JobItemPrepService> CreateJobItemPrepServices(JobItem source, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
return BuildJobItemPrepServices(
|
||||
source.PrepServices?.Select(p => new JobItemPrepServiceSeed
|
||||
{
|
||||
PrepServiceId = p.PrepServiceId,
|
||||
EstimatedMinutes = p.EstimatedMinutes,
|
||||
BlastSetupId = p.BlastSetupId
|
||||
}),
|
||||
jobItemId,
|
||||
companyId,
|
||||
createdAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItem"/> creation paths.
|
||||
/// Centralised here so that adding a new field only requires one code change, not three.
|
||||
/// </summary>
|
||||
private static JobItem BuildJobItem(JobItemSeed seed, int jobId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItem
|
||||
{
|
||||
JobId = jobId,
|
||||
Description = seed.Description,
|
||||
Quantity = seed.Quantity,
|
||||
ColorName = seed.ColorName,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
SurfaceArea = seed.SurfaceArea,
|
||||
SurfaceAreaSqFt = seed.SurfaceAreaSqFt,
|
||||
CatalogItemId = seed.CatalogItemId,
|
||||
IsGenericItem = seed.IsGenericItem,
|
||||
IsLaborItem = seed.IsLaborItem,
|
||||
IsSalesItem = seed.IsSalesItem,
|
||||
IsAiItem = seed.IsAiItem,
|
||||
Sku = seed.Sku,
|
||||
ManualUnitPrice = seed.ManualUnitPrice,
|
||||
PowderCostOverride = seed.PowderCostOverride,
|
||||
UnitPrice = seed.UnitPrice,
|
||||
TotalPrice = seed.TotalPrice,
|
||||
LaborCost = seed.LaborCost,
|
||||
RequiresSandblasting = seed.RequiresSandblasting,
|
||||
RequiresMasking = seed.RequiresMasking,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
Notes = seed.Notes,
|
||||
IncludePrepCost = seed.IncludePrepCost,
|
||||
Complexity = seed.Complexity,
|
||||
AiTags = seed.AiTags,
|
||||
AiPredictionId = seed.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemCoat"/> creation paths.
|
||||
/// </summary>
|
||||
private static JobItemCoat BuildJobItemCoat(JobItemCoatSeed seed, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new JobItemCoat
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
CoatName = seed.CoatName,
|
||||
Sequence = seed.Sequence,
|
||||
InventoryItemId = seed.InventoryItemId,
|
||||
ColorName = seed.ColorName,
|
||||
VendorId = seed.VendorId,
|
||||
ColorCode = seed.ColorCode,
|
||||
Finish = seed.Finish,
|
||||
CoverageSqFtPerLb = seed.CoverageSqFtPerLb,
|
||||
TransferEfficiency = seed.TransferEfficiency,
|
||||
PowderCostPerLb = seed.PowderCostPerLb,
|
||||
PowderToOrder = seed.PowderToOrder,
|
||||
Notes = seed.Notes,
|
||||
NoExtraLayerCharge = seed.NoExtraLayerCharge,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single construction point for all <see cref="JobItemPrepService"/> creation paths.
|
||||
/// Returns an empty list (not null) when <paramref name="seeds"/> is null so callers
|
||||
/// can safely iterate without a null check.
|
||||
/// </summary>
|
||||
private static IReadOnlyList<JobItemPrepService> BuildJobItemPrepServices(IEnumerable<JobItemPrepServiceSeed>? seeds, int jobItemId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return seeds?
|
||||
.Select(seed => new JobItemPrepService
|
||||
{
|
||||
JobItemId = jobItemId,
|
||||
PrepServiceId = seed.PrepServiceId,
|
||||
EstimatedMinutes = seed.EstimatedMinutes,
|
||||
BlastSetupId = seed.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList() ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the pounds of powder needed to coat a batch, preferring the pre-stored value
|
||||
/// (which the user may have manually adjusted in the wizard) over a fresh recalculation.
|
||||
///
|
||||
/// Formula: (surfaceAreaSqFt × quantity) ÷ (coverageSqFtPerLb × transferEfficiency)
|
||||
///
|
||||
/// Industry defaults are applied when catalog data is missing:
|
||||
/// - Coverage: 30 sqft/lb (typical for standard powder at 2–3 mil DFT)
|
||||
/// - Transfer efficiency: 65% (industry average for electrostatic spray)
|
||||
/// These are conservative defaults that slightly overestimate powder needed — intentional,
|
||||
/// so the shop doesn't run short on a job.
|
||||
/// </summary>
|
||||
private static decimal? CalculatePowderToOrder(decimal? storedPowderToOrder, decimal surfaceAreaSqFt, decimal quantity, decimal coverageSqFtPerLb, decimal transferEfficiency)
|
||||
{
|
||||
if (storedPowderToOrder.HasValue && storedPowderToOrder.Value > 0)
|
||||
return storedPowderToOrder;
|
||||
|
||||
if (surfaceAreaSqFt <= 0)
|
||||
return null;
|
||||
|
||||
var coverage = coverageSqFtPerLb > 0 ? coverageSqFtPerLb : 30m;
|
||||
var efficiency = transferEfficiency > 0 ? transferEfficiency / 100m : 0.65m;
|
||||
return Math.Round((surfaceAreaSqFt * quantity) / (coverage * efficiency), 2);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the display appearance (color name, code, finish) for a coat, preferring the linked
|
||||
/// <see cref="InventoryItem"/>'s values over whatever was typed into the quote form.
|
||||
/// The inventory record is the canonical source of truth — the form values are used as a fallback
|
||||
/// only when no inventory item is linked (e.g. custom/one-off powder).
|
||||
/// </summary>
|
||||
private static (string? ColorName, string? ColorCode, string? Finish) ResolveCoatAppearance(
|
||||
string? colorName,
|
||||
string? colorCode,
|
||||
string? finish,
|
||||
InventoryItem? inventoryItem)
|
||||
{
|
||||
if (inventoryItem == null)
|
||||
return (colorName, colorCode, finish);
|
||||
|
||||
return (inventoryItem.Name, inventoryItem.ColorCode, inventoryItem.Finish);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Intermediate value object that normalises the three different source types
|
||||
/// (DTO, QuoteItem, JobItem) into a single shape before the shared BuildJobItem factory method.
|
||||
/// Using a seed class prevents subtle bugs where an overload forgets to map a new field.
|
||||
/// </summary>
|
||||
private sealed class JobItemSeed
|
||||
{
|
||||
public string Description { get; init; } = string.Empty;
|
||||
public decimal Quantity { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal? SurfaceArea { get; init; }
|
||||
public decimal SurfaceAreaSqFt { get; init; }
|
||||
public int? CatalogItemId { get; init; }
|
||||
public bool IsGenericItem { get; init; }
|
||||
public bool IsLaborItem { get; init; }
|
||||
public bool IsSalesItem { get; init; }
|
||||
public bool IsAiItem { get; init; }
|
||||
public string? Sku { get; init; }
|
||||
public decimal? ManualUnitPrice { get; init; }
|
||||
public decimal? PowderCostOverride { get; init; }
|
||||
public decimal UnitPrice { get; init; }
|
||||
public decimal TotalPrice { get; init; }
|
||||
public decimal LaborCost { get; init; }
|
||||
public bool RequiresSandblasting { get; init; }
|
||||
public bool RequiresMasking { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool IncludePrepCost { get; init; }
|
||||
public string? Complexity { get; init; }
|
||||
public string? AiTags { get; init; }
|
||||
public int? AiPredictionId { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for coat creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemCoatSeed
|
||||
{
|
||||
public string CoatName { get; init; } = string.Empty;
|
||||
public int Sequence { get; init; }
|
||||
public int? InventoryItemId { get; init; }
|
||||
public string? ColorName { get; init; }
|
||||
public int? VendorId { get; init; }
|
||||
public string? ColorCode { get; init; }
|
||||
public string? Finish { get; init; }
|
||||
public decimal CoverageSqFtPerLb { get; init; }
|
||||
public decimal TransferEfficiency { get; init; }
|
||||
public decimal? PowderCostPerLb { get; init; }
|
||||
public decimal? PowderToOrder { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public bool NoExtraLayerCharge { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Intermediate value object for prep service creation — see <see cref="JobItemSeed"/> for rationale.</summary>
|
||||
private sealed class JobItemPrepServiceSeed
|
||||
{
|
||||
public int PrepServiceId { get; init; }
|
||||
public int EstimatedMinutes { get; init; }
|
||||
public int? BlastSetupId { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -69,19 +69,13 @@ public class JobPhotoService : IJobPhotoService
|
||||
string? caption = null,
|
||||
JobPhotoType photoType = JobPhotoType.Progress)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// SECURITY: Use GUID for blob name to prevent enumeration
|
||||
var blobName = $"{companyId}/job-photos/{jobId}/{Guid.NewGuid()}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.JobImages, blobName, stream, contentType);
|
||||
@@ -137,19 +131,4 @@ public class JobPhotoService : IJobPhotoService
|
||||
return await _blobService.ExistsAsync(_settings.Containers.JobImages, filePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1858,6 +1858,50 @@ public class PdfService : IPdfService
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a multi-page PDF containing one gift certificate per page, all using the same
|
||||
/// branded layout as the single-certificate download. Used for bulk print runs (car shows,
|
||||
/// promotions) so staff can hand-cut and distribute a full batch from one print job.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateBulkGiftCertificatePdfAsync(
|
||||
IList<GiftCertificateDto> certs,
|
||||
byte[]? companyLogo,
|
||||
string? companyLogoContentType,
|
||||
CompanyInfoDto companyInfo)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#7c3aed";
|
||||
const string gold = "#b45309";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var doc = Document.Create(container =>
|
||||
{
|
||||
foreach (var cert in certs)
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.75f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(10).FontFamily("Arial"));
|
||||
|
||||
page.Content().Element(c => ComposeGiftCertificateContent(c, cert, companyInfo, companyLogo, accent, gold));
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.Span(companyInfo.CompanyName).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
if (!string.IsNullOrWhiteSpace(companyInfo.Phone))
|
||||
text.Span($" · {FormatPhoneNumber(companyInfo.Phone)}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return doc.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composes the gift certificate body with a decorative double-border frame (outer purple 3pt,
|
||||
/// inner gold 1pt) that gives the document a premium printed-certificate appearance. Inside the
|
||||
@@ -2357,4 +2401,356 @@ public class PdfService : IPdfService
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates an Accounts Payable Aging PDF. Layout mirrors GenerateArAgingPdfAsync:
|
||||
/// a KPI summary band, a per-vendor summary table with aging columns, then a bill-detail
|
||||
/// section grouped by vendor. Uses a red accent palette to visually distinguish AP from AR.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateApAgingPdfAsync(ApAgingReportDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#b91c1c";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Accounts Payable Aging",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Current", dto.TotalCurrent.ToString("C0"), "#16a34a");
|
||||
KpiCell(row, "1–30 Days", dto.Total1to30.ToString("C0"), "#ca8a04");
|
||||
KpiCell(row, "31–60 Days", dto.Total31to60.ToString("C0"), "#ea580c");
|
||||
KpiCell(row, "61–90 Days", dto.Total61to90.ToString("C0"), "#dc2626");
|
||||
KpiCell(row, "Over 90", dto.TotalOver90.ToString("C0"), "#7f1d1d");
|
||||
KpiCell(row, "Total Owed", dto.TotalOutstanding.ToString("C0"), accent);
|
||||
});
|
||||
|
||||
if (!dto.Vendors.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("All bills are paid — no outstanding balances.")
|
||||
.FontSize(11).FontColor("#16a34a");
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(3);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Vendor", "Current", "1–30", "31–60", "61–90", "Over 90", "Total" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(vend.VendorName).FontSize(9).Bold();
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalCurrent > 0 ? vend.TotalCurrent.ToString("C") : "—").FontSize(9).FontColor("#16a34a");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total1to30 > 0 ? vend.Total1to30.ToString("C") : "—").FontSize(9).FontColor("#ca8a04");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total31to60 > 0 ? vend.Total31to60.ToString("C") : "—").FontSize(9).FontColor("#ea580c");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.Total61to90 > 0 ? vend.Total61to90.ToString("C") : "—").FontSize(9).FontColor("#dc2626");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalOver90 > 0 ? vend.TotalOver90.ToString("C") : "—").FontSize(9).FontColor("#7f1d1d");
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(vend.TotalBalance.ToString("C")).FontSize(9).Bold();
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCurrent.ToString("C")).FontSize(9).Bold().FontColor("#16a34a");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total1to30.ToString("C")).FontSize(9).Bold().FontColor("#ca8a04");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total31to60.ToString("C")).FontSize(9).Bold().FontColor("#ea580c");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.Total61to90.ToString("C")).FontSize(9).Bold().FontColor("#dc2626");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOver90.ToString("C")).FontSize(9).Bold().FontColor("#7f1d1d");
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalOutstanding.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(16).Text("Bill Detail").FontSize(11).Bold();
|
||||
|
||||
foreach (var vend in dto.Vendors)
|
||||
{
|
||||
col.Item().PaddingTop(8).ShowEntire().Column(vendCol =>
|
||||
{
|
||||
vendCol.Item().Background("#f1f5f9").Padding(4).Text(vend.VendorName).Bold().FontSize(10);
|
||||
|
||||
vendCol.Item().Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Bill #", "Bill Date", "Due Date", "Balance", "Age" })
|
||||
h.Cell().Background("#e2e8f0").Padding(3).Text(lbl).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
foreach (var bill in vend.Bills.OrderBy(b => b.DaysOverdue))
|
||||
{
|
||||
var ageColor = bill.DaysOverdue <= 0 ? "#16a34a"
|
||||
: bill.DaysOverdue <= 30 ? "#ca8a04"
|
||||
: bill.DaysOverdue <= 60 ? "#ea580c"
|
||||
: bill.DaysOverdue <= 90 ? "#dc2626"
|
||||
: "#7f1d1d";
|
||||
var ageLabel = bill.DaysOverdue <= 0 ? "Current" : $"{bill.DaysOverdue}d overdue";
|
||||
|
||||
table.Cell().Padding(3).Text(bill.BillNumber).FontSize(8);
|
||||
table.Cell().Padding(3).Text(bill.BillDate.ToString("MM/dd/yyyy")).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Padding(3).Text(bill.DueDate?.ToString("MM/dd/yyyy") ?? "—").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().AlignRight().Padding(3).Text(bill.BalanceDue.ToString("C")).Bold().FontSize(8)
|
||||
.FontColor(bill.DaysOverdue > 30 ? "#dc2626" : "#000000");
|
||||
table.Cell().Padding(3).Text(ageLabel).FontSize(8).FontColor(ageColor);
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#f1f5f9").AlignRight().Padding(3)
|
||||
.Text($"{vend.VendorName} subtotal").Bold().FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background("#f1f5f9").AlignRight().Padding(3).Text(vend.TotalBalance.ToString("C")).Bold().FontSize(8);
|
||||
table.Cell().Background("#f1f5f9");
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Trial Balance PDF. Each active account appears once with its balance in either
|
||||
/// the Debit or Credit column based on AccountingRules sign conventions. A footer row shows
|
||||
/// totals and a balanced/unbalanced indicator.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateTrialBalancePdfAsync(TrialBalanceDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#1a56db";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Trial Balance",
|
||||
$"As of {dto.AsOf:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Item().Background("#f8fafc").Border(1).BorderColor("#e2e8f0").Padding(8).Row(row =>
|
||||
{
|
||||
KpiCell(row, "Total Debits", dto.TotalDebits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Total Credits", dto.TotalCredits.ToString("C0"), "#1a56db");
|
||||
KpiCell(row, "Status", dto.IsBalanced ? "Balanced ✓" : "Out of Balance ✗",
|
||||
dto.IsBalanced ? "#16a34a" : "#dc2626");
|
||||
});
|
||||
|
||||
if (!dto.Lines.Any())
|
||||
{
|
||||
col.Item().PaddingTop(20).AlignCenter()
|
||||
.Text("No active accounts with balances found.")
|
||||
.FontSize(11).FontColor(Colors.Grey.Darken1);
|
||||
return;
|
||||
}
|
||||
|
||||
col.Item().PaddingTop(14).Table(table =>
|
||||
{
|
||||
table.ColumnsDefinition(cols =>
|
||||
{
|
||||
cols.ConstantColumn(70);
|
||||
cols.RelativeColumn(4);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
cols.RelativeColumn(2);
|
||||
});
|
||||
|
||||
table.Header(h =>
|
||||
{
|
||||
foreach (var lbl in new[] { "Acct #", "Account Name", "Type", "Debit", "Credit" })
|
||||
h.Cell().Background(accent).Padding(4).Text(lbl).FontColor(Colors.White).Bold().FontSize(8);
|
||||
});
|
||||
|
||||
var alt = false;
|
||||
foreach (var line in dto.Lines)
|
||||
{
|
||||
var bg = alt ? "#f8fafc" : "#ffffff";
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountNumber).FontSize(8).FontColor(Colors.Grey.Darken2);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountName).FontSize(9);
|
||||
table.Cell().Background(bg).Padding(4).Text(line.AccountType.ToString()).FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.DebitBalance > 0 ? line.DebitBalance.ToString("C") : "").FontSize(9);
|
||||
table.Cell().Background(bg).AlignRight().Padding(4).Text(line.CreditBalance > 0 ? line.CreditBalance.ToString("C") : "").FontSize(9);
|
||||
alt = !alt;
|
||||
}
|
||||
|
||||
table.Cell().ColumnSpan(3).Background("#e2e8f0").Padding(4).Text("Total").FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalDebits.ToString("C")).FontSize(9).Bold();
|
||||
table.Cell().Background("#e2e8f0").AlignRight().Padding(4).Text(dto.TotalCredits.ToString("C")).FontSize(9).Bold();
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a Cash Flow Statement PDF with three sections (Operating, Investing, Financing)
|
||||
/// plus a summary reconciling beginning → ending cash. Uses a teal accent palette to
|
||||
/// visually distinguish it from the other financial statements.
|
||||
/// </summary>
|
||||
public async Task<byte[]> GenerateCashFlowStatementPdfAsync(CashFlowStatementDto dto)
|
||||
{
|
||||
QuestPDF.Settings.License = LicenseType.Community;
|
||||
const string accent = "#0891b2";
|
||||
|
||||
return await Task.Run(() =>
|
||||
{
|
||||
var document = Document.Create(container =>
|
||||
{
|
||||
container.Page(page =>
|
||||
{
|
||||
page.Size(PageSizes.Letter);
|
||||
page.Margin(0.6f, Unit.Inch);
|
||||
page.PageColor(Colors.White);
|
||||
page.DefaultTextStyle(x => x.FontSize(9).FontFamily("Arial"));
|
||||
|
||||
page.Header().Element(c => ComposeReportHeader(c, dto.CompanyName, "Cash Flow Statement",
|
||||
$"{dto.From:MMMM d, yyyy} – {dto.To:MMMM d, yyyy}", accent));
|
||||
|
||||
page.Content().PaddingTop(12).Column(col =>
|
||||
{
|
||||
col.Spacing(4);
|
||||
|
||||
// ── Operating Activities ──────────────────────────────────────
|
||||
col.Item().Text("Operating Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
CfRow(t, "Cash Received from Customers", dto.CashFromCustomers, false);
|
||||
CfRow(t, "Cash Paid to Vendors (Bills)", -dto.CashToVendors, false);
|
||||
CfRow(t, "Cash Paid for Expenses", -dto.CashForExpenses, false);
|
||||
CfTotalRow(t, "Net Cash from Operating Activities", dto.NetOperating);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Investing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.InvestingLines.Count == 0)
|
||||
CfRow(t, "No investing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.InvestingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Investing Activities", dto.NetInvesting);
|
||||
});
|
||||
|
||||
col.Item().PaddingTop(10).Text("Financing Activities").Bold().FontSize(11).FontColor(accent);
|
||||
col.Item().Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
if (dto.FinancingLines.Count == 0)
|
||||
CfRow(t, "No financing activities recorded", 0, true);
|
||||
else
|
||||
foreach (var line in dto.FinancingLines)
|
||||
CfRow(t, line.Label, line.Amount, false);
|
||||
CfTotalRow(t, "Net Cash from Financing Activities", dto.NetFinancing);
|
||||
});
|
||||
|
||||
// ── Summary ───────────────────────────────────────────────────
|
||||
col.Item().PaddingTop(12).Table(t =>
|
||||
{
|
||||
t.ColumnsDefinition(c => { c.RelativeColumn(3); c.ConstantColumn(100); });
|
||||
|
||||
void SumRow(string label, decimal amount, bool bold = false)
|
||||
{
|
||||
var bg = bold ? "#e0f2fe" : "#ffffff";
|
||||
var lText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).Text(label).FontSize(9);
|
||||
if (bold) lText.Bold();
|
||||
var vText = t.Cell().Background(bg).PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
if (bold) vText.Bold();
|
||||
}
|
||||
|
||||
SumRow("Beginning Cash Balance", dto.BeginningCash);
|
||||
SumRow("Net Change in Cash", dto.NetChangeInCash);
|
||||
SumRow("Ending Cash Balance", dto.EndingCash, bold: true);
|
||||
});
|
||||
});
|
||||
|
||||
page.Footer().AlignCenter().Text(text =>
|
||||
{
|
||||
text.CurrentPageNumber(); text.Span(" / "); text.TotalPages();
|
||||
text.Span($" · {dto.CompanyName} · Generated {DateTime.Now:MMM d, yyyy}").FontSize(8).FontColor(Colors.Grey.Darken1);
|
||||
});
|
||||
});
|
||||
});
|
||||
return document.GeneratePdf();
|
||||
});
|
||||
|
||||
static void CfRow(TableDescriptor t, string label, decimal amount, bool muted)
|
||||
{
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6)
|
||||
.Text(label).FontSize(9).FontColor(muted ? Colors.Grey.Medium : Colors.Black);
|
||||
t.Cell().BorderBottom(0.5f).BorderColor("#e5e7eb")
|
||||
.PaddingVertical(3).PaddingHorizontal(6).AlignRight()
|
||||
.Text(muted ? "" : amount.ToString("C")).FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
|
||||
static void CfTotalRow(TableDescriptor t, string label, decimal amount)
|
||||
{
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6)
|
||||
.Text(label).Bold().FontSize(9);
|
||||
t.Cell().Background("#f0f9ff").PaddingVertical(4).PaddingHorizontal(6).AlignRight()
|
||||
.Text(amount.ToString("C")).Bold().FontSize(9)
|
||||
.FontColor(amount < 0 ? Colors.Red.Darken2 : Colors.Black);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,53 +590,9 @@ public class PricingCalculationService : IPricingCalculationService
|
||||
{
|
||||
QuoteItemPricingResult itemResult;
|
||||
|
||||
// Catalog items - if they have coats, add coat costs to catalog base price
|
||||
if (item.CatalogItemId.HasValue)
|
||||
{
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(item.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
// If the catalog item has coats, calculate using CalculateQuoteItemPriceAsync
|
||||
// (which already includes the catalog base price + coat costs)
|
||||
if (item.Coats != null && item.Coats.Any())
|
||||
{
|
||||
// CalculateQuoteItemPriceAsync already adds catalog base price to coat costs
|
||||
// All items (catalog and calculated) go through CalculateQuoteItemPriceAsync, which
|
||||
// handles PowderCostOverride, prep cost inclusion, and all item type variants.
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
else
|
||||
{
|
||||
// No coats - use simple catalog default price
|
||||
var catalogItemTotal = catalogItem.DefaultPrice * item.Quantity;
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = catalogItemTotal,
|
||||
UnitPrice = catalogItem.DefaultPrice,
|
||||
TotalPrice = catalogItemTotal
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Catalog item not found, create zero result
|
||||
itemResult = new QuoteItemPricingResult
|
||||
{
|
||||
MaterialCost = 0,
|
||||
LaborCost = 0,
|
||||
EquipmentCost = 0,
|
||||
ItemSubtotal = 0,
|
||||
UnitPrice = 0,
|
||||
TotalPrice = 0
|
||||
};
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Calculated items use the full pricing calculation
|
||||
itemResult = await CalculateQuoteItemPriceAsync(item, companyId, ovenCostOverride);
|
||||
}
|
||||
|
||||
itemResults.Add(itemResult);
|
||||
}
|
||||
|
||||
@@ -66,22 +66,16 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
string userId,
|
||||
int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, "No file was uploaded.");
|
||||
|
||||
if (file.Length > MaxPhotoSize)
|
||||
return (false, string.Empty, "Photo must be smaller than 10 MB.");
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(extension) || !AllowedImageTypes.Contains(extension))
|
||||
return (false, string.Empty, "Only JPG, PNG, GIF, and WebP images are allowed.");
|
||||
var (isValid, extension, error) = BlobFileHelper.ValidateUpload(file, AllowedImageTypes, MaxPhotoSize);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, error);
|
||||
|
||||
// Delete old photos for this user with different extensions
|
||||
await DeleteOldPhotosForUserAsync(companyId, userId, extension);
|
||||
|
||||
// Blob path mirrors former filesystem path
|
||||
var blobName = $"{companyId}/profile-photos/{userId}{extension}";
|
||||
var contentType = GetContentType(extension);
|
||||
var contentType = BlobFileHelper.GetContentType(extension);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.ProfileImages, blobName, stream, contentType);
|
||||
@@ -172,19 +166,4 @@ public class ProfilePhotoService : IProfilePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type.
|
||||
/// Falls back to <c>image/jpeg</c> (rather than octet-stream) because all
|
||||
/// allowed extensions are image types and browsers will render them correctly.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,19 +50,13 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
public async Task<(bool Success, string TempId, string FilePath, string ErrorMessage)> SaveTempPhotoAsync(
|
||||
IFormFile file, int companyId)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
return (false, string.Empty, string.Empty, "No file provided.");
|
||||
|
||||
if (file.Length > MaxFileSizeBytes)
|
||||
return (false, string.Empty, string.Empty, "File exceeds the 10 MB limit.");
|
||||
|
||||
var ext = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
if (!AllowedExtensions.Contains(ext))
|
||||
return (false, string.Empty, string.Empty, $"File type '{ext}' is not allowed.");
|
||||
var (isValid, ext, validationError) = BlobFileHelper.ValidateUpload(file, AllowedExtensions, MaxFileSizeBytes);
|
||||
if (!isValid)
|
||||
return (false, string.Empty, string.Empty, validationError);
|
||||
|
||||
var tempId = Guid.NewGuid().ToString("N");
|
||||
var blobName = $"temp/{tempId}/{Guid.NewGuid():N}{ext}";
|
||||
var contentType = GetContentType(ext);
|
||||
var contentType = BlobFileHelper.GetContentType(ext);
|
||||
|
||||
using var stream = file.OpenReadStream();
|
||||
var result = await _blobService.UploadAsync(_settings.Containers.QuoteImages, blobName, stream, contentType);
|
||||
@@ -100,7 +94,7 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
return (false, string.Empty, "Failed to read temp photo.");
|
||||
|
||||
using var ms = new MemoryStream(download.Content);
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, GetContentType(ext));
|
||||
var upload = await _blobService.UploadAsync(_settings.Containers.QuoteImages, destBlob, ms, BlobFileHelper.GetContentType(ext));
|
||||
if (!upload.Success)
|
||||
return (false, string.Empty, "Failed to save permanent photo.");
|
||||
|
||||
@@ -173,12 +167,4 @@ public class QuotePhotoService : IQuotePhotoService
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetContentType(string ext) => ext switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
_ => "image/jpeg"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,453 @@
|
||||
using PowderCoating.Application.DTOs.Quote;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates the full quote item assembly pipeline: pricing calculation, entity construction,
|
||||
/// AI prediction tracking, and automatic inventory record creation for incoming powder orders.
|
||||
///
|
||||
/// This service sits above <see cref="PricingCalculationService"/> — it knows HOW to build and
|
||||
/// persist quote entities, while PricingCalculationService knows HOW to compute dollar amounts.
|
||||
/// Keeping them separate means pricing logic can be unit-tested without any entity construction concerns.
|
||||
///
|
||||
/// Key responsibilities:
|
||||
/// - <see cref="ApplyPricingSnapshot"/> — stamps calculated totals onto the Quote entity so the
|
||||
/// displayed price is frozen at quote time and won't change if operating costs are updated later.
|
||||
/// - <see cref="CreateQuoteItemsAsync"/> — builds QuoteItem + coats + prep services for each DTO,
|
||||
/// records AI prediction overrides, and auto-creates incoming inventory records when needed.
|
||||
/// </summary>
|
||||
public class QuotePricingAssemblyService : IQuotePricingAssemblyService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IPricingCalculationService _pricingService;
|
||||
private readonly IInventoryAiLookupService _aiLookupService;
|
||||
private readonly ILogger<QuotePricingAssemblyService> _logger;
|
||||
|
||||
public QuotePricingAssemblyService(
|
||||
IUnitOfWork unitOfWork,
|
||||
IPricingCalculationService pricingService,
|
||||
IInventoryAiLookupService aiLookupService,
|
||||
ILogger<QuotePricingAssemblyService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_pricingService = pricingService;
|
||||
_aiLookupService = aiLookupService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the calculated pricing breakdown onto the <see cref="Quote"/> entity as a snapshot.
|
||||
/// Snapshots are critical: once a quote is sent to a customer, operating cost changes must NOT
|
||||
/// silently alter the quoted amounts — the snapshot preserves what was presented at the time.
|
||||
/// </summary>
|
||||
public void ApplyPricingSnapshot(Quote quote, QuotePricingResult pricingResult)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(quote);
|
||||
ArgumentNullException.ThrowIfNull(pricingResult);
|
||||
|
||||
quote.MaterialCosts = pricingResult.MaterialCosts;
|
||||
quote.LaborCosts = pricingResult.LaborCosts;
|
||||
quote.EquipmentCosts = pricingResult.EquipmentCosts;
|
||||
quote.ItemsSubtotal = pricingResult.ItemsSubtotal;
|
||||
quote.OvenBatchCost = pricingResult.OvenBatchCost;
|
||||
quote.FacilityOverheadCost = pricingResult.FacilityOverheadCost;
|
||||
quote.FacilityOverheadRatePerHour = pricingResult.FacilityOverheadRatePerHour;
|
||||
quote.ShopSuppliesAmount = pricingResult.ShopSuppliesAmount;
|
||||
quote.ShopSuppliesPercent = pricingResult.ShopSuppliesPercent;
|
||||
quote.OverheadAmount = pricingResult.OverheadCosts;
|
||||
quote.OverheadPercent = pricingResult.OverheadPercent;
|
||||
quote.ProfitMargin = pricingResult.ProfitMargin;
|
||||
quote.ProfitPercent = pricingResult.ProfitPercent;
|
||||
quote.SubTotal = pricingResult.SubtotalBeforeDiscount;
|
||||
quote.PricingTierDiscountAmount = pricingResult.PricingTierDiscountAmount;
|
||||
quote.PricingTierDiscountPercent = pricingResult.PricingTierDiscountPercent;
|
||||
quote.QuoteDiscountAmount = pricingResult.QuoteDiscountAmount;
|
||||
quote.QuoteDiscountPercent = pricingResult.QuoteDiscountPercent;
|
||||
quote.DiscountPercent = pricingResult.DiscountPercent;
|
||||
quote.DiscountAmount = pricingResult.DiscountAmount;
|
||||
quote.SubtotalAfterDiscount = pricingResult.SubtotalAfterDiscount;
|
||||
quote.RushFee = pricingResult.RushFee;
|
||||
quote.TaxAmount = pricingResult.TaxAmount;
|
||||
quote.Total = pricingResult.Total;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and prices all <see cref="QuoteItem"/> entities from the incoming DTOs.
|
||||
/// For each item: constructs the entity, calculates pricing, records whether the user overrode
|
||||
/// an AI estimate, then attaches coats (including auto-creating incoming inventory entries when
|
||||
/// the user selects a catalog powder not yet in their inventory) and prep services.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<QuoteItem>> CreateQuoteItemsAsync(
|
||||
IEnumerable<CreateQuoteItemDto> itemDtos,
|
||||
int quoteId,
|
||||
int companyId,
|
||||
decimal? ovenRateOverride,
|
||||
DateTime createdAtUtc)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(itemDtos);
|
||||
|
||||
var items = new List<QuoteItem>();
|
||||
foreach (var itemDto in itemDtos)
|
||||
{
|
||||
var item = BuildQuoteItem(itemDto, quoteId, companyId, createdAtUtc);
|
||||
await ApplyPricingAsync(item, itemDto, companyId, ovenRateOverride);
|
||||
await UpdateAiPredictionOverrideAsync(itemDto, item.UnitPrice);
|
||||
|
||||
item.Coats = await BuildQuoteItemCoatsAsync(itemDto, companyId, createdAtUtc);
|
||||
item.PrepServices = BuildQuoteItemPrepServices(itemDto, companyId, createdAtUtc);
|
||||
items.Add(item);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Routes a single item to the correct pricing path and stamps the result onto the entity.
|
||||
/// Priority order matches the routing table in <see cref="PricingCalculationService.CalculateQuoteItemPriceAsync"/>:
|
||||
/// AI items → Sales items → Catalog (no coats) → full calculation engine.
|
||||
/// Keeping pricing logic in PricingCalculationService means this method only decides WHICH
|
||||
/// path to take, never HOW to compute the price.
|
||||
/// </summary>
|
||||
private async Task ApplyPricingAsync(QuoteItem item, CreateQuoteItemDto itemDto, int companyId, decimal? ovenRateOverride)
|
||||
{
|
||||
if (itemDto.IsAiItem && itemDto.ManualUnitPrice.HasValue && itemDto.ManualUnitPrice.Value > 0)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("AI item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.IsSalesItem && itemDto.ManualUnitPrice.HasValue)
|
||||
{
|
||||
item.UnitPrice = itemDto.ManualUnitPrice.Value;
|
||||
item.TotalPrice = itemDto.ManualUnitPrice.Value * itemDto.Quantity;
|
||||
_logger.LogInformation("Sales item price: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
return;
|
||||
}
|
||||
|
||||
if (itemDto.CatalogItemId.HasValue)
|
||||
{
|
||||
if (itemDto.Coats != null && itemDto.Coats.Any())
|
||||
{
|
||||
_logger.LogInformation("Calculating catalog item with {CoatCount} coats", itemDto.Coats.Count);
|
||||
var itemPricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, itemPricing);
|
||||
return;
|
||||
}
|
||||
|
||||
var catalogItem = await _unitOfWork.CatalogItems.GetByIdAsync(itemDto.CatalogItemId.Value);
|
||||
if (catalogItem != null)
|
||||
{
|
||||
var unitPrice = itemDto.PowderCostOverride is > 0
|
||||
? itemDto.PowderCostOverride.Value
|
||||
: catalogItem.DefaultPrice;
|
||||
item.UnitPrice = unitPrice;
|
||||
item.TotalPrice = unitPrice * itemDto.Quantity;
|
||||
_logger.LogInformation("Catalog item no coats: UnitPrice={Unit}, TotalPrice={Total}", item.UnitPrice, item.TotalPrice);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Calculating custom item with {CoatCount} coats", itemDto.Coats?.Count ?? 0);
|
||||
var pricing = await _pricingService.CalculateQuoteItemPriceAsync(itemDto, companyId, ovenRateOverride);
|
||||
ApplyCalculatedPricing(item, pricing);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds <see cref="QuoteItemCoat"/> entities for a single item, including per-coat pricing.
|
||||
/// If a coat has <c>AddAsIncoming = true</c> and references a catalog item but not an inventory
|
||||
/// item, an incoming <see cref="InventoryItem"/> is auto-created so the shop can track the powder
|
||||
/// order and receive it later — see <see cref="CreateIncomingInventoryItemAsync"/> for details.
|
||||
/// </summary>
|
||||
private async Task<List<QuoteItemCoat>> BuildQuoteItemCoatsAsync(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.Coats == null || itemDto.Coats.Count == 0)
|
||||
return [];
|
||||
|
||||
var coats = new List<QuoteItemCoat>();
|
||||
for (var coatIndex = 0; coatIndex < itemDto.Coats.Count; coatIndex++)
|
||||
{
|
||||
var coatDto = itemDto.Coats[coatIndex];
|
||||
|
||||
if (coatDto.AddAsIncoming && coatDto.CatalogItemId.HasValue && !coatDto.InventoryItemId.HasValue)
|
||||
coatDto.InventoryItemId = await CreateIncomingInventoryItemAsync(coatDto, companyId);
|
||||
|
||||
var coat = BuildQuoteItemCoat(coatDto, companyId, createdAtUtc);
|
||||
var coatPricing = await _pricingService.CalculateCoatPriceAsync(
|
||||
coatDto,
|
||||
itemDto.SurfaceAreaSqFt,
|
||||
itemDto.Quantity,
|
||||
coatIndex,
|
||||
itemDto.EstimatedMinutes,
|
||||
companyId);
|
||||
|
||||
coat.CoatMaterialCost = coatPricing.CoatMaterialCost;
|
||||
coat.CoatLaborCost = coatPricing.CoatLaborCost;
|
||||
coat.CoatTotalCost = coatPricing.CoatTotalCost;
|
||||
coats.Add(coat);
|
||||
}
|
||||
|
||||
return coats;
|
||||
}
|
||||
|
||||
/// <summary>Constructs <see cref="QuoteItemPrepService"/> entities from the item DTO's prep service list.</summary>
|
||||
private static List<QuoteItemPrepService> BuildQuoteItemPrepServices(CreateQuoteItemDto itemDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
if (itemDto.PrepServices == null || itemDto.PrepServices.Count == 0)
|
||||
return [];
|
||||
|
||||
return itemDto.PrepServices
|
||||
.Select(ps => new QuoteItemPrepService
|
||||
{
|
||||
PrepServiceId = ps.PrepServiceId,
|
||||
EstimatedMinutes = ps.EstimatedMinutes,
|
||||
BlastSetupId = ps.BlastSetupId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs a bare <see cref="QuoteItem"/> entity from the DTO — no pricing or coats yet.
|
||||
/// Pricing is applied separately by <see cref="ApplyPricingAsync"/> to keep the construction
|
||||
/// and calculation steps distinct and individually testable.
|
||||
/// </summary>
|
||||
private static QuoteItem BuildQuoteItem(CreateQuoteItemDto itemDto, int quoteId, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItem
|
||||
{
|
||||
QuoteId = quoteId,
|
||||
Description = itemDto.Description,
|
||||
Quantity = itemDto.Quantity,
|
||||
SurfaceAreaSqFt = itemDto.SurfaceAreaSqFt,
|
||||
CatalogItemId = itemDto.CatalogItemId,
|
||||
IsGenericItem = itemDto.IsGenericItem,
|
||||
ManualUnitPrice = itemDto.ManualUnitPrice,
|
||||
PowderCostOverride = itemDto.PowderCostOverride,
|
||||
IsLaborItem = itemDto.IsLaborItem,
|
||||
IsSalesItem = itemDto.IsSalesItem,
|
||||
Sku = itemDto.Sku,
|
||||
RequiresSandblasting = itemDto.RequiresSandblasting,
|
||||
RequiresMasking = itemDto.RequiresMasking,
|
||||
EstimatedMinutes = itemDto.EstimatedMinutes,
|
||||
IncludePrepCost = itemDto.IncludePrepCost,
|
||||
Notes = itemDto.Notes,
|
||||
Complexity = itemDto.Complexity,
|
||||
IsAiItem = itemDto.IsAiItem,
|
||||
AiTags = itemDto.AiTags,
|
||||
AiPredictionId = itemDto.AiPredictionId,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Constructs a <see cref="QuoteItemCoat"/> entity from the coat DTO. Per-coat pricing is applied by the caller.</summary>
|
||||
private static QuoteItemCoat BuildQuoteItemCoat(CreateQuoteItemCoatDto coatDto, int companyId, DateTime createdAtUtc)
|
||||
{
|
||||
return new QuoteItemCoat
|
||||
{
|
||||
CoatName = coatDto.CoatName,
|
||||
Sequence = coatDto.Sequence,
|
||||
InventoryItemId = coatDto.InventoryItemId,
|
||||
ColorName = coatDto.ColorName,
|
||||
VendorId = coatDto.VendorId,
|
||||
ColorCode = coatDto.ColorCode,
|
||||
Finish = coatDto.Finish,
|
||||
CoverageSqFtPerLb = coatDto.CoverageSqFtPerLb,
|
||||
TransferEfficiency = coatDto.TransferEfficiency,
|
||||
PowderCostPerLb = coatDto.PowderCostPerLb,
|
||||
PowderToOrder = coatDto.PowderToOrder,
|
||||
NoExtraLayerCharge = coatDto.NoExtraLayerCharge,
|
||||
Notes = coatDto.Notes,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = createdAtUtc
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps the pricing result onto the quote item entity.
|
||||
/// Broken out as a separate method because it's called from multiple branches of ApplyPricingAsync.
|
||||
/// </summary>
|
||||
private static void ApplyCalculatedPricing(QuoteItem item, QuoteItemPricingResult pricing)
|
||||
{
|
||||
item.UnitPrice = pricing.UnitPrice;
|
||||
item.TotalPrice = pricing.TotalPrice;
|
||||
item.ItemMaterialCost = pricing.MaterialCost;
|
||||
item.ItemLaborCost = pricing.LaborCost;
|
||||
item.ItemEquipmentCost = pricing.EquipmentCost;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the user changed the AI's surface area or price estimates before saving,
|
||||
/// and sets <c>UserOverrodeEstimate = true</c> on the prediction record if they did.
|
||||
/// This flag feeds the AI analytics reports — over time it reveals how accurate the AI is
|
||||
/// and whether certain item types consistently need manual correction.
|
||||
/// A tolerance of $0.01 / 0.01 sqft is used to ignore floating-point rounding noise.
|
||||
/// </summary>
|
||||
private async Task UpdateAiPredictionOverrideAsync(CreateQuoteItemDto itemDto, decimal finalUnitPrice)
|
||||
{
|
||||
if (!itemDto.AiPredictionId.HasValue) return;
|
||||
|
||||
var prediction = await _unitOfWork.AiItemPredictions.GetByIdAsync(itemDto.AiPredictionId.Value);
|
||||
if (prediction == null) return;
|
||||
|
||||
var sqftDiff = Math.Abs(prediction.PredictedSurfaceAreaSqFt - itemDto.SurfaceAreaSqFt);
|
||||
var priceDiff = Math.Abs(prediction.PredictedUnitPrice - finalUnitPrice);
|
||||
prediction.UserOverrodeEstimate = sqftDiff > 0.01m || priceDiff > 0.01m;
|
||||
prediction.UpdatedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-creates an "incoming" <see cref="InventoryItem"/> when a user selects a powder from the
|
||||
/// platform catalog that doesn't yet exist in their company's inventory.
|
||||
///
|
||||
/// WHY this exists: shops often quote jobs using powders they haven't ordered yet. Rather than
|
||||
/// forcing the user to manually add the powder to inventory before quoting, we create an
|
||||
/// IsIncoming=true record on their behalf. The shop can then receive the actual order against
|
||||
/// this record later (updating quantity + receive date) without losing the link to the original quote.
|
||||
///
|
||||
/// The AI augmentation step (LookupByUrlAsync) fills in technical specs (cure temp/time, coverage,
|
||||
/// color families, etc.) that may be missing from the scraped catalog JSON. It is best-effort —
|
||||
/// if it fails, the item is still created with whatever data the catalog has.
|
||||
///
|
||||
/// After creation, <c>coatDto.PowderCostPerLb</c> is cleared so the pricing engine treats this
|
||||
/// as an inventory-linked coat (not a custom powder), ensuring future repricings use the
|
||||
/// inventory unit cost rather than the now-stale manual price from the quote form.
|
||||
/// </summary>
|
||||
private async Task<int?> CreateIncomingInventoryItemAsync(CreateQuoteItemCoatDto coatDto, int companyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var catalogItem = await _unitOfWork.PowderCatalog.GetByIdAsync(coatDto.CatalogItemId!.Value);
|
||||
if (catalogItem == null) return null;
|
||||
|
||||
var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync();
|
||||
var coatingCategory = categories
|
||||
.Where(c => c.IsActive && c.IsCoating)
|
||||
.OrderBy(c => c.DisplayOrder)
|
||||
.FirstOrDefault();
|
||||
|
||||
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||
var vendorNameLower = catalogItem.VendorName.ToLower();
|
||||
var matchedVendor = vendors.FirstOrDefault(v =>
|
||||
v.CompanyName.ToLower().Contains(vendorNameLower) ||
|
||||
vendorNameLower.Contains(v.CompanyName.ToLower()));
|
||||
|
||||
var code = coatingCategory != null
|
||||
? (coatingCategory.CategoryCode.Length >= 4
|
||||
? coatingCategory.CategoryCode[..4].ToUpperInvariant()
|
||||
: coatingCategory.CategoryCode.ToUpperInvariant().PadRight(4, 'X'))
|
||||
: "POWD";
|
||||
var prefix = $"{code}-{DateTime.Now:yyMM}-";
|
||||
var allItems = await _unitOfWork.InventoryItems.GetAllAsync(ignoreQueryFilters: true);
|
||||
var maxSeq = allItems
|
||||
.Where(i => i.SKU.StartsWith(prefix))
|
||||
.Select(i => int.TryParse(i.SKU[prefix.Length..], out var n) ? n : 0)
|
||||
.DefaultIfEmpty(0)
|
||||
.Max();
|
||||
var sku = $"{prefix}{(maxSeq + 1):D4}";
|
||||
|
||||
var name = System.Globalization.CultureInfo.CurrentCulture.TextInfo
|
||||
.ToTitleCase(catalogItem.ColorName.Trim().ToLower());
|
||||
|
||||
var description = catalogItem.Description;
|
||||
var finish = catalogItem.Finish;
|
||||
var colorFamilies = catalogItem.ColorFamilies;
|
||||
var cureTemp = catalogItem.CureTemperatureF;
|
||||
var cureTime = catalogItem.CureTimeMinutes;
|
||||
var coverage = catalogItem.CoverageSqFtPerLb;
|
||||
var transferEff = catalogItem.TransferEfficiency;
|
||||
var specificGravity = catalogItem.SpecificGravity;
|
||||
var imageUrl = catalogItem.ImageUrl;
|
||||
var sdsUrl = catalogItem.SdsUrl;
|
||||
var tdsUrl = catalogItem.TdsUrl;
|
||||
|
||||
var needsAugment = !string.IsNullOrWhiteSpace(catalogItem.ProductUrl) &&
|
||||
(string.IsNullOrWhiteSpace(description) ||
|
||||
string.IsNullOrWhiteSpace(colorFamilies) ||
|
||||
cureTemp == null || cureTime == null);
|
||||
if (needsAugment)
|
||||
{
|
||||
try
|
||||
{
|
||||
var augmented = await _aiLookupService.LookupByUrlAsync(catalogItem.ProductUrl!, catalogItem.ColorName, catalogItem.TdsUrl);
|
||||
if (augmented.Success)
|
||||
{
|
||||
description = string.IsNullOrWhiteSpace(description) ? augmented.Description : description;
|
||||
finish = string.IsNullOrWhiteSpace(finish) ? augmented.Finish : finish;
|
||||
colorFamilies = string.IsNullOrWhiteSpace(colorFamilies) ? augmented.ColorFamilies : colorFamilies;
|
||||
cureTemp ??= augmented.CureTemperatureF;
|
||||
cureTime ??= augmented.CureTimeMinutes;
|
||||
coverage ??= augmented.CoverageSqFtPerLb;
|
||||
transferEff ??= augmented.TransferEfficiency;
|
||||
specificGravity ??= augmented.SpecificGravity;
|
||||
imageUrl = string.IsNullOrWhiteSpace(imageUrl) ? augmented.ImageUrl : imageUrl;
|
||||
sdsUrl = string.IsNullOrWhiteSpace(sdsUrl) ? augmented.SdsUrl : sdsUrl;
|
||||
tdsUrl = string.IsNullOrWhiteSpace(tdsUrl) ? augmented.TdsUrl : tdsUrl;
|
||||
_logger.LogInformation("AI-augmented incoming inventory item for catalog {CatalogId}", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "AI augment failed for catalog {CatalogId}, continuing with catalog data", catalogItem.Id);
|
||||
}
|
||||
}
|
||||
|
||||
var item = new InventoryItem
|
||||
{
|
||||
SKU = sku,
|
||||
Name = name,
|
||||
Description = description,
|
||||
ColorName = catalogItem.ColorName,
|
||||
Manufacturer = catalogItem.VendorName,
|
||||
ManufacturerPartNumber = catalogItem.Sku,
|
||||
Finish = finish,
|
||||
ColorFamilies = colorFamilies,
|
||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||
CoverageSqFtPerLb = coverage ?? 30m,
|
||||
TransferEfficiency = transferEff ?? 65m,
|
||||
CureTemperatureF = cureTemp,
|
||||
CureTimeMinutes = cureTime,
|
||||
SpecificGravity = specificGravity,
|
||||
SpecPageUrl = catalogItem.ProductUrl,
|
||||
ImageUrl = imageUrl,
|
||||
SdsUrl = sdsUrl,
|
||||
TdsUrl = tdsUrl,
|
||||
UnitCost = catalogItem.UnitPrice,
|
||||
AverageCost = catalogItem.UnitPrice,
|
||||
LastPurchasePrice = catalogItem.UnitPrice,
|
||||
QuantityOnHand = 0,
|
||||
UnitOfMeasure = "lbs",
|
||||
PrimaryVendorId = matchedVendor?.Id,
|
||||
InventoryCategoryId = coatingCategory?.Id,
|
||||
Category = coatingCategory?.DisplayName ?? "Powder Coating",
|
||||
IsActive = true,
|
||||
IsIncoming = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||
await _unitOfWork.SaveChangesAsync();
|
||||
|
||||
coatDto.PowderCostPerLb = null;
|
||||
_logger.LogInformation("Created incoming inventory item {Id} ({Name}) from catalog {CatalogId} via quote coat",
|
||||
item.Id, item.Name, coatDto.CatalogItemId);
|
||||
|
||||
return item.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to create incoming inventory item from catalog {CatalogId}, continuing without inventory link",
|
||||
coatDto.CatalogItemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,10 @@ public class BillPayment : BaseEntity
|
||||
public string? CheckNumber { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
@@ -150,9 +154,305 @@ public class Expense : BaseEntity
|
||||
public string? Memo { get; set; }
|
||||
public string? ReceiptFilePath { get; set; }
|
||||
|
||||
/// <summary>True once this expense has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor? Vendor { get; set; }
|
||||
public virtual Account ExpenseAccount { get; set; } = null!;
|
||||
public virtual Account PaymentAccount { get; set; } = null!;
|
||||
public virtual Job? Job { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual double-entry journal entry. Lines must balance (sum of debits == sum of credits)
|
||||
/// before posting. Once posted the entry is immutable — use Reverse to correct it.
|
||||
/// Entry numbering follows the pattern JE-YYMM-#### scoped per company.
|
||||
/// </summary>
|
||||
public class JournalEntry : BaseEntity
|
||||
{
|
||||
public string EntryNumber { get; set; } = string.Empty;
|
||||
public DateTime EntryDate { get; set; } = DateTime.UtcNow;
|
||||
public string? Reference { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public JournalEntryStatus Status { get; set; } = JournalEntryStatus.Draft;
|
||||
|
||||
/// <summary>True if this entry was machine-generated as a reversal of another entry.</summary>
|
||||
public bool IsReversal { get; set; } = false;
|
||||
/// <summary>FK to the original entry being reversed. Null for normal entries.</summary>
|
||||
public int? ReversalOfId { get; set; }
|
||||
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public string? PostedBy { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<JournalEntryLine> Lines { get; set; } = new List<JournalEntryLine>();
|
||||
public virtual JournalEntry? ReversalOf { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One debit or credit line within a <see cref="JournalEntry"/>. Either DebitAmount or CreditAmount
|
||||
/// should be non-zero per line (not both). LineOrder controls display sequence.
|
||||
/// </summary>
|
||||
public class JournalEntryLine : BaseEntity
|
||||
{
|
||||
public int JournalEntryId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
public decimal DebitAmount { get; set; }
|
||||
public decimal CreditAmount { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public int LineOrder { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bank reconciliation session for a single bank/cash account against a statement.
|
||||
/// Cleared balance = BeginningBalance + cleared deposits - cleared payments.
|
||||
/// The reconciliation is complete when Difference (EndingBalance - ClearedBalance) == 0.
|
||||
/// </summary>
|
||||
public class BankReconciliation : BaseEntity
|
||||
{
|
||||
/// <summary>Must be a bank/cash subtype account.</summary>
|
||||
public int AccountId { get; set; }
|
||||
public DateTime StatementDate { get; set; }
|
||||
public decimal BeginningBalance { get; set; }
|
||||
public decimal EndingBalance { get; set; }
|
||||
public BankReconciliationStatus Status { get; set; } = BankReconciliationStatus.InProgress;
|
||||
public DateTime? CompletedAt { get; set; }
|
||||
public string? CompletedBy { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A credit note received from a vendor (returned goods, pricing dispute, short-ship).
|
||||
/// Reduces Accounts Payable and reverses the original expense/COGS when posted.
|
||||
/// Numbering: VC-YYMM-####
|
||||
/// </summary>
|
||||
public class VendorCredit : BaseEntity
|
||||
{
|
||||
public string CreditNumber { get; set; } = string.Empty;
|
||||
public int VendorId { get; set; }
|
||||
/// <summary>AP account this credit reduces (default: Accounts Payable 2000).</summary>
|
||||
public int APAccountId { get; set; }
|
||||
public DateTime CreditDate { get; set; } = DateTime.UtcNow;
|
||||
public VendorCreditStatus Status { get; set; } = VendorCreditStatus.Open;
|
||||
public decimal Total { get; set; }
|
||||
public decimal RemainingAmount { get; set; }
|
||||
public string? Memo { get; set; }
|
||||
/// <summary>Set by Post() when GL entries are made (DR AP / CR expense lines). Null = unposted.</summary>
|
||||
public DateTime? PostedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Vendor Vendor { get; set; } = null!;
|
||||
public virtual Account APAccount { get; set; } = null!;
|
||||
public virtual ICollection<VendorCreditLineItem> LineItems { get; set; } = new List<VendorCreditLineItem>();
|
||||
public virtual ICollection<VendorCreditApplication> Applications { get; set; } = new List<VendorCreditApplication>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single line on a vendor credit, each reversing a specific expense/COGS account.
|
||||
/// </summary>
|
||||
public class VendorCreditLineItem : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
/// <summary>Expense/COGS account being reversed by this line.</summary>
|
||||
public int? AccountId { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public decimal Amount { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Account? Account { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the application of a vendor credit against a specific vendor bill.
|
||||
/// No additional GL posting is needed — AP was already adjusted when the credit was posted.
|
||||
/// </summary>
|
||||
public class VendorCreditApplication : BaseEntity
|
||||
{
|
||||
public int VendorCreditId { get; set; }
|
||||
public int BillId { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
public DateTime AppliedDate { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Navigation
|
||||
public virtual VendorCredit VendorCredit { get; set; } = null!;
|
||||
public virtual Bill Bill { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A saved recipe for a document that should be automatically created on a recurring schedule.
|
||||
/// The <see cref="TemplateData"/> column stores a JSON blob whose schema depends on
|
||||
/// <see cref="TemplateType"/>: see <c>RecurringTransactionService</c> for the exact shape.
|
||||
/// <para>
|
||||
/// Bills are created as Draft so the user can review before posting.
|
||||
/// Expenses are created immediately (already-paid transactions).
|
||||
/// </para>
|
||||
/// Numbering: REC-YYMM-####
|
||||
/// </summary>
|
||||
public class RecurringTemplate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public RecurringTemplateType TemplateType { get; set; }
|
||||
public RecurringFrequency Frequency { get; set; }
|
||||
/// <summary>Every N periods. E.g. Frequency=Monthly, IntervalCount=3 → quarterly.</summary>
|
||||
public int IntervalCount { get; set; } = 1;
|
||||
/// <summary>UTC date when the template will next fire. Set to the desired first occurrence date on creation.</summary>
|
||||
public DateTime NextFireDate { get; set; }
|
||||
/// <summary>Optional UTC date after which no further occurrences are generated.</summary>
|
||||
public DateTime? EndDate { get; set; }
|
||||
/// <summary>Optional hard cap on total occurrences. Null = unlimited.</summary>
|
||||
public int? MaxOccurrences { get; set; }
|
||||
/// <summary>How many documents have been generated so far.</summary>
|
||||
public int OccurrenceCount { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
/// <summary>JSON payload whose schema matches the TemplateType. See RecurringTransactionService.</summary>
|
||||
public string TemplateData { get; set; } = "{}";
|
||||
/// <summary>Last error from the background service, cleared on next successful fire.</summary>
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named tax rate (e.g., "CA Sales Tax 8.25%") used to pre-fill the TaxPercent field on
|
||||
/// invoices when a taxable customer is selected. Companies can define multiple rates for
|
||||
/// different jurisdictions and mark one as default.
|
||||
/// </summary>
|
||||
public class TaxRate : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>Rate as a percentage, e.g., 8.25 means 8.25%.</summary>
|
||||
public decimal Rate { get; set; }
|
||||
public string? State { get; set; }
|
||||
public string? Description { get; set; }
|
||||
/// <summary>When true, this rate is auto-applied to new invoices for taxable customers.</summary>
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A depreciable fixed asset (oven, blast cabinet, spray booth, vehicle, etc.).
|
||||
/// Stores straight-line depreciation parameters and links to the three GL accounts needed
|
||||
/// to auto-post monthly depreciation journal entries.
|
||||
/// </summary>
|
||||
public class FixedAsset : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Description { get; set; }
|
||||
public DateTime PurchaseDate { get; set; }
|
||||
public decimal PurchaseCost { get; set; }
|
||||
/// <summary>Residual value at end of useful life (often $0 for shop equipment).</summary>
|
||||
public decimal SalvageValue { get; set; } = 0;
|
||||
/// <summary>Total depreciation period in months (e.g., 60 = 5 years).</summary>
|
||||
public int UsefulLifeMonths { get; set; }
|
||||
/// <summary>Running total of depreciation posted so far.</summary>
|
||||
public decimal AccumulatedDepreciation { get; set; } = 0;
|
||||
public bool IsDisposed { get; set; } = false;
|
||||
public DateTime? DisposalDate { get; set; }
|
||||
|
||||
// Computed — not persisted
|
||||
/// <summary>Current net book value: PurchaseCost minus AccumulatedDepreciation.</summary>
|
||||
public decimal BookValue => PurchaseCost - AccumulatedDepreciation;
|
||||
/// <summary>Straight-line monthly depreciation amount.</summary>
|
||||
public decimal MonthlyDepreciation => UsefulLifeMonths > 0
|
||||
? Math.Round((PurchaseCost - SalvageValue) / UsefulLifeMonths, 2) : 0;
|
||||
|
||||
// GL account links — all optional; assets without accounts can be tracked but not auto-posted
|
||||
/// <summary>Balance Sheet FixedAsset account (debited when asset is purchased).</summary>
|
||||
public int? AssetAccountId { get; set; }
|
||||
/// <summary>P&L Depreciation Expense account (debited each period).</summary>
|
||||
public int? DepreciationExpenseAccountId { get; set; }
|
||||
/// <summary>Balance Sheet Accumulated Depreciation account (credited each period).</summary>
|
||||
public int? AccumDepreciationAccountId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Account? AssetAccount { get; set; }
|
||||
public virtual Account? DepreciationExpenseAccount { get; set; }
|
||||
public virtual Account? AccumDepreciationAccount { get; set; }
|
||||
public virtual ICollection<FixedAssetDepreciationEntry> DepreciationEntries { get; set; } = new List<FixedAssetDepreciationEntry>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records each periodic depreciation posting for a fixed asset. One record per asset per
|
||||
/// month/year combination; linked to the JournalEntry that was created so the posting
|
||||
/// can be traced back through the GL.
|
||||
/// </summary>
|
||||
public class FixedAssetDepreciationEntry : BaseEntity
|
||||
{
|
||||
public int FixedAssetId { get; set; }
|
||||
public int PeriodYear { get; set; }
|
||||
public int PeriodMonth { get; set; }
|
||||
public decimal Amount { get; set; }
|
||||
/// <summary>The JE that was posted for this depreciation period (null if manually recorded).</summary>
|
||||
public int? JournalEntryId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual FixedAsset FixedAsset { get; set; } = null!;
|
||||
public virtual JournalEntry? JournalEntry { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A named annual budget. Contains one BudgetLine per account per month. Supports
|
||||
/// multiple budgets per fiscal year (e.g. "Conservative" vs "Optimistic") but only
|
||||
/// one is marked IsDefault for the Budget vs. Actual report.
|
||||
/// </summary>
|
||||
public class Budget : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int FiscalYear { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public bool IsDefault { get; set; } = false;
|
||||
|
||||
public virtual ICollection<BudgetLine> Lines { get; set; } = new List<BudgetLine>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Monthly budget amount for one account within a Budget. Jan–Dec stored as separate
|
||||
/// columns so the grid editor can write them in a single POST without a line-item loop.
|
||||
/// Annual is a computed property summing all twelve months.
|
||||
/// </summary>
|
||||
public class BudgetLine : BaseEntity
|
||||
{
|
||||
public int BudgetId { get; set; }
|
||||
public int AccountId { get; set; }
|
||||
|
||||
public decimal Jan { get; set; }
|
||||
public decimal Feb { get; set; }
|
||||
public decimal Mar { get; set; }
|
||||
public decimal Apr { get; set; }
|
||||
public decimal May { get; set; }
|
||||
public decimal Jun { get; set; }
|
||||
public decimal Jul { get; set; }
|
||||
public decimal Aug { get; set; }
|
||||
public decimal Sep { get; set; }
|
||||
public decimal Oct { get; set; }
|
||||
public decimal Nov { get; set; }
|
||||
public decimal Dec { get; set; }
|
||||
|
||||
public decimal Annual => Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec;
|
||||
|
||||
public virtual Budget Budget { get; set; } = null!;
|
||||
public virtual Account Account { get; set; } = null!;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a completed year-end close. The close posts a JE that zeroes all
|
||||
/// Revenue and Expense account balances into Retained Earnings, and marks
|
||||
/// the year as closed so it cannot be closed again.
|
||||
/// </summary>
|
||||
public class YearEndClose : BaseEntity
|
||||
{
|
||||
public int ClosedYear { get; set; }
|
||||
public DateTime ClosedAt { get; set; } = DateTime.UtcNow;
|
||||
public string? ClosedBy { get; set; }
|
||||
public int JournalEntryId { get; set; }
|
||||
|
||||
public virtual JournalEntry JournalEntry { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ public class ApplicationUser : IdentityUser
|
||||
public bool CanManageMaintenance { get; set; } = false;
|
||||
public bool CanManageInvoices { get; set; } = false;
|
||||
public bool CanViewReports { get; set; } = false;
|
||||
public bool CanManageBills { get; set; } = false;
|
||||
public bool CanManageAccounting { get; set; } = false;
|
||||
|
||||
// Profile Photo (filesystem storage)
|
||||
public string? ProfilePictureFilePath { get; set; } // Relative path from ContentRoot/media/ (e.g., "123/profile-photos/user-abc.jpg")
|
||||
@@ -57,6 +59,13 @@ public class ApplicationUser : IdentityUser
|
||||
public string? SidebarColor { get; set; } = "ocean";
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Per-worker labor cost per hour used for job costing profit/margin calculations.
|
||||
/// Overrides the company-level LaborCostPerHour when set.
|
||||
/// Leave null to use the company default.
|
||||
/// </summary>
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
public DateTime? LastLoginDate { get; set; }
|
||||
|
||||
@@ -95,6 +95,12 @@ public class Appointment : BaseEntity
|
||||
/// </summary>
|
||||
public int ReminderMinutesBefore { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// UTC timestamp when the reminder was dispatched. Null means it hasn't fired yet.
|
||||
/// The background service uses this as a deduplication guard to prevent double-sending.
|
||||
/// </summary>
|
||||
public DateTime? ReminderSentAt { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual Customer? Customer { get; set; }
|
||||
public virtual Job? Job { get; set; }
|
||||
|
||||
@@ -105,11 +105,34 @@ public class Company : BaseEntity
|
||||
public bool MarketingEmailOptOut { get; set; } = false;
|
||||
public string MarketingUnsubscribeToken { get; set; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether financial reports (P&L, Balance Sheet, Cash Flow) use
|
||||
/// cash-basis or accrual-basis presentation. Switchable at any time — no GL
|
||||
/// re-posting occurs. Default is Accrual (standard for most businesses).
|
||||
/// </summary>
|
||||
public AccountingMethod AccountingMethod { get; set; } = AccountingMethod.Accrual;
|
||||
|
||||
/// <summary>
|
||||
/// When set, prevents creating or editing accounting entries (JEs, bills, expenses) with dates
|
||||
/// on or before this date. Protects closed periods from accidental backdating. Null = no lock.
|
||||
/// </summary>
|
||||
public DateTime? BookLockedThrough { get; set; }
|
||||
|
||||
// Settings
|
||||
public string? TimeZone { get; set; } = "America/New_York";
|
||||
public byte[]? LogoData { get; set; } // Legacy - kept for backward compatibility
|
||||
public string? LogoContentType { get; set; } // Legacy - kept for backward compatibility
|
||||
public string? LogoFilePath { get; set; } // Filesystem path: /media/{CompanyId}/company-logo.{ext}
|
||||
|
||||
// Kiosk
|
||||
/// <summary>
|
||||
/// Random token written to a long-lived HttpOnly cookie on the front-desk tablet when the
|
||||
/// owner activates the kiosk. Kiosk routes validate this token against the cookie so the
|
||||
/// tablet can serve the intake form without requiring a logged-in user.
|
||||
/// Null = kiosk not activated. Regenerate to revoke the current device.
|
||||
/// </summary>
|
||||
public string? KioskActivationToken { get; set; }
|
||||
|
||||
// Navigation Properties
|
||||
public virtual ICollection<ApplicationUser> Users { get; set; } = new List<ApplicationUser>();
|
||||
public virtual ICollection<Customer> Customers { get; set; } = new List<Customer>();
|
||||
@@ -118,7 +141,6 @@ public class Company : BaseEntity
|
||||
public virtual ICollection<Quote> Quotes { get; set; } = new List<Quote>();
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Vendor> Vendors { get; set; } = new List<Vendor>();
|
||||
public virtual ICollection<ShopWorker> ShopWorkers { get; set; } = new List<ShopWorker>();
|
||||
public virtual ICollection<PricingTier> PricingTiers { get; set; } = new List<PricingTier>();
|
||||
public virtual CompanyOperatingCosts? OperatingCosts { get; set; }
|
||||
public virtual CompanyPreferences? Preferences { get; set; }
|
||||
|
||||
@@ -13,6 +13,14 @@ namespace PowderCoating.Core.Entities
|
||||
[Range(0, 10000)]
|
||||
public decimal StandardLaborRate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Actual labor cost per hour (wages + burden) used exclusively for internal job costing and profit/margin display.
|
||||
/// This is NOT the billing rate — it should reflect what you actually pay workers.
|
||||
/// When null, the costing engine defaults to 20% of StandardLaborRate.
|
||||
/// </summary>
|
||||
[Range(0, 10000)]
|
||||
public decimal? LaborCostPerHour { get; set; }
|
||||
|
||||
// Additional Coat Labor Percentage (percentage of base labor for each additional coat beyond the first)
|
||||
[Range(0, 100)]
|
||||
public decimal AdditionalCoatLaborPercent { get; set; } = 30m;
|
||||
|
||||
@@ -86,6 +86,14 @@ public class CompanyPreferences : BaseEntity
|
||||
/// <summary>JSON blob persisting QB Migration Wizard step state across sessions.</summary>
|
||||
public string? QbMigrationStateJson { get; set; }
|
||||
|
||||
// Kiosk settings
|
||||
/// <summary>
|
||||
/// Controls what the kiosk creates on submission: "Quote" (default) or "Job".
|
||||
/// Quote aligns with the default Terms text ("subject to a formal quote").
|
||||
/// Job is for shops that price on the spot and want the work order ready immediately.
|
||||
/// </summary>
|
||||
public string KioskIntakeOutput { get; set; } = "Quote";
|
||||
|
||||
// Guided activation / first-workflow onboarding
|
||||
/// <summary>Selected first-workflow path: quote_first or job_first. Null until chosen.</summary>
|
||||
public string? OnboardingPath { get; set; }
|
||||
|
||||
@@ -15,6 +15,10 @@ public class Deposit : BaseEntity
|
||||
public string? Notes { get; set; }
|
||||
public string? RecordedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account this deposit was deposited into. Set at recording time
|
||||
/// so the Trial Balance can immediately debit the correct bank account.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// Applied to invoice when invoice is created
|
||||
public int? AppliedToInvoiceId { get; set; }
|
||||
public DateTime? AppliedDate { get; set; }
|
||||
|
||||
@@ -32,6 +32,9 @@ public class GiftCertificate : BaseEntity
|
||||
/// <summary>Set when this GC was sold via an invoice line item.</summary>
|
||||
public int? SourceInvoiceItemId { get; set; }
|
||||
|
||||
/// <summary>Groups all certificates created in a single bulk run. Null for individually issued certs.</summary>
|
||||
public Guid? BatchId { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Customer? RecipientCustomer { get; set; }
|
||||
public virtual Customer? PurchasingCustomer { get; set; }
|
||||
|
||||
@@ -28,6 +28,13 @@ public class Invoice : BaseEntity
|
||||
public decimal GiftCertificateRedeemed { get; set; } // Sum of gift certificate redemptions
|
||||
public decimal BalanceDue => Total - AmountPaid - CreditApplied - GiftCertificateRedeemed;
|
||||
|
||||
/// <summary>
|
||||
/// Permanent public token for the customer-facing invoice view page (/invoice/{token}).
|
||||
/// Generated when the invoice is first sent (regardless of Stripe status) and never expires.
|
||||
/// Distinct from PaymentLinkToken which is Stripe-gated and expires in 5 days.
|
||||
/// </summary>
|
||||
public string? PublicViewToken { get; set; }
|
||||
|
||||
// Online payments (Stripe Connect)
|
||||
public OnlinePaymentStatus OnlinePaymentStatus { get; set; } = OnlinePaymentStatus.NotApplicable;
|
||||
public string? PaymentLinkToken { get; set; } // Signed token for /pay/{token}
|
||||
@@ -42,6 +49,19 @@ public class Invoice : BaseEntity
|
||||
public string? Terms { get; set; }
|
||||
public string? CustomerPO { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Early payment discount percentage (e.g., 2 means 2% discount).
|
||||
/// Parsed from the customer's payment terms when the invoice is created (e.g., "2/10 Net 30").
|
||||
/// Informational only — does not automatically reduce the amount due.
|
||||
/// </summary>
|
||||
public decimal EarlyPaymentDiscountPercent { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of days after invoice date within which the early payment discount applies.
|
||||
/// Parsed from the customer's payment terms (e.g., "2/10 Net 30" → 10 days).
|
||||
/// </summary>
|
||||
public int EarlyPaymentDiscountDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original invoice number from an external system (e.g. QuickBooks invoice # "3048").
|
||||
/// Stored for searchability and traceability after import. Searchable from the invoice list.
|
||||
|
||||
@@ -25,9 +25,14 @@ public class Job : BaseEntity
|
||||
// Selected oven (carried over from quote; null = company default rate)
|
||||
public int? OvenCostId { get; set; }
|
||||
|
||||
// Oven scheduling (carried over from quote)
|
||||
public int OvenBatches { get; set; } = 1;
|
||||
public int? OvenCycleMinutes { get; set; }
|
||||
|
||||
// Pricing
|
||||
public decimal QuotedPrice { get; set; }
|
||||
public decimal FinalPrice { get; set; }
|
||||
public decimal OvenBatchCost { get; set; }
|
||||
public decimal ShopSuppliesAmount { get; set; }
|
||||
public decimal ShopSuppliesPercent { get; set; }
|
||||
|
||||
@@ -61,6 +66,10 @@ public class Job : BaseEntity
|
||||
// Used to detect when the quote was subsequently edited so the job details page can warn the user.
|
||||
public DateTime? QuoteSnapshotUpdatedAt { get; set; }
|
||||
|
||||
// Pricing snapshot — serialized QuotePricingBreakdownDto stored at save time so Details displays
|
||||
// the breakdown that was actually calculated, not a re-run against current operating costs.
|
||||
public string? PricingBreakdownJson { get; set; }
|
||||
|
||||
// Rework tracking
|
||||
public bool IsReworkJob { get; set; }
|
||||
public int? OriginalJobId { get; set; } // Set when this job was created as a rework
|
||||
|
||||
@@ -41,6 +41,10 @@ public class JobItem : BaseEntity
|
||||
// Values: "Simple" | "Moderate" | "Complex" | "Extreme"
|
||||
public string? Complexity { get; set; }
|
||||
|
||||
// True when this item originated from an AI Photo Quote — ManualUnitPrice is used as-is
|
||||
// and oven cost is not double-charged (it was excluded from the AI estimate at quote level).
|
||||
public bool IsAiItem { get; set; }
|
||||
|
||||
// AI-generated standardized tags (comma-separated, e.g. "automotive,tubular")
|
||||
public string? AiTags { get; set; }
|
||||
|
||||
|
||||
@@ -42,6 +42,13 @@ public class JobItemCoat : BaseEntity
|
||||
public string? PowderReceivedByUserId { get; set; }
|
||||
public decimal? PowderReceivedLbs { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ public class JobTemplateItem : BaseEntity
|
||||
public int? CatalogItemId { get; set; }
|
||||
public bool IsGenericItem { get; set; }
|
||||
public bool IsLaborItem { get; set; }
|
||||
public bool IsSalesItem { get; set; }
|
||||
public string? Sku { get; set; }
|
||||
public decimal? ManualUnitPrice { get; set; }
|
||||
public bool RequiresSandblasting { get; set; }
|
||||
public bool RequiresMasking { get; set; }
|
||||
|
||||
@@ -3,7 +3,6 @@ namespace PowderCoating.Core.Entities;
|
||||
public class JobTimeEntry : BaseEntity
|
||||
{
|
||||
public int JobId { get; set; }
|
||||
public int? ShopWorkerId { get; set; } // legacy — kept for entries created before user migration
|
||||
public string? UserId { get; set; } // FK to AspNetUsers
|
||||
public string? UserDisplayName { get; set; } // snapshot of worker name at entry creation time
|
||||
public DateTime WorkDate { get; set; }
|
||||
@@ -13,5 +12,4 @@ public class JobTimeEntry : BaseEntity
|
||||
|
||||
// Navigation
|
||||
public virtual Job Job { get; set; } = null!;
|
||||
public virtual ShopWorker? Worker { get; set; } // nullable — only populated for legacy entries
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Represents one customer self-service intake session — either completed on the front-desk tablet
|
||||
/// (InPerson) or via an emailed link the customer fills out on their own device (Remote).
|
||||
/// Sessions are tenant-scoped and soft-deletable. Load anonymous sessions with ignoreQueryFilters:true.
|
||||
/// </summary>
|
||||
public class KioskSession : BaseEntity
|
||||
{
|
||||
/// <summary>URL-safe GUID used in all kiosk routes; unique across the table.</summary>
|
||||
public Guid SessionToken { get; set; } = Guid.NewGuid();
|
||||
|
||||
public KioskSessionType SessionType { get; set; }
|
||||
public KioskSessionStatus Status { get; set; } = KioskSessionStatus.Active;
|
||||
|
||||
// ── Step 1 — Contact ─────────────────────────────────────────────────────
|
||||
public string CustomerFirstName { get; set; } = string.Empty;
|
||||
public string CustomerLastName { get; set; } = string.Empty;
|
||||
public string CustomerPhone { get; set; } = string.Empty;
|
||||
public string CustomerEmail { get; set; } = string.Empty;
|
||||
public bool IsReturningCustomer { get; set; }
|
||||
|
||||
// ── Step 2 — Job Description ──────────────────────────────────────────────
|
||||
public string JobDescription { get; set; } = string.Empty;
|
||||
public string? HowDidYouHearAboutUs { get; set; }
|
||||
|
||||
// ── Step 3 — Terms & Consent ──────────────────────────────────────────────
|
||||
public bool AgreedToTerms { get; set; }
|
||||
public DateTime? AgreedToTermsAt { get; set; }
|
||||
/// <summary>Customer opted in to SMS order updates; sets Customer.NotifyBySms on submission.</summary>
|
||||
public bool SmsOptIn { get; set; }
|
||||
/// <summary>Base-64 PNG from signature_pad; null for Remote sessions (no drawn signature required).</summary>
|
||||
public string? SignatureDataBase64 { get; set; }
|
||||
|
||||
// ── Outcome ───────────────────────────────────────────────────────────────
|
||||
public int? LinkedCustomerId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Job". Null when a Quote was created instead.</summary>
|
||||
public int? LinkedJobId { get; set; }
|
||||
/// <summary>Set when KioskIntakeOutput = "Quote". Null when a Job was created instead.</summary>
|
||||
public int? LinkedQuoteId { get; set; }
|
||||
public DateTime? SubmittedAt { get; set; }
|
||||
/// <summary>Sessions auto-expire 2 h after creation (InPerson) or 48 h (Remote). ExpiresAt is set at creation.</summary>
|
||||
public DateTime ExpiresAt { get; set; }
|
||||
|
||||
// ── Remote-only ───────────────────────────────────────────────────────────
|
||||
public string? RemoteLinkEmail { get; set; }
|
||||
public DateTime? RemoteLinkSentAt { get; set; }
|
||||
|
||||
// ── Navigation ────────────────────────────────────────────────────────────
|
||||
public virtual Customer? LinkedCustomer { get; set; }
|
||||
public virtual Job? LinkedJob { get; set; }
|
||||
}
|
||||
@@ -18,6 +18,10 @@ public class Payment : BaseEntity
|
||||
/// </summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
/// <summary>True once this payment has been matched against a bank statement during reconciliation.</summary>
|
||||
public bool IsCleared { get; set; } = false;
|
||||
public DateTime? ClearedDate { get; set; }
|
||||
|
||||
// Navigation
|
||||
public virtual Invoice Invoice { get; set; } = null!;
|
||||
public virtual ApplicationUser? RecordedBy { get; set; }
|
||||
|
||||
@@ -45,21 +45,28 @@ public class Quote : BaseEntity
|
||||
public decimal EquipmentCosts { get; set; } // Sum of equipment costs across all items
|
||||
public decimal ItemsSubtotal { get; set; } // Sum of item prices before any quote-level costs
|
||||
public decimal OvenBatchCost { get; set; } // Oven batch charge applied at quote level
|
||||
public decimal FacilityOverheadCost { get; set; } // Rent + utilities apportioned by estimated job hours
|
||||
public decimal FacilityOverheadRatePerHour { get; set; }// Rate used for facility overhead ($/hr)
|
||||
public decimal ShopSuppliesAmount { get; set; } // Shop supplies dollar amount
|
||||
public decimal ShopSuppliesPercent { get; set; } // Shop supplies percentage used
|
||||
public decimal OverheadAmount { get; set; } // Overhead dollar amount
|
||||
public decimal OverheadPercent { get; set; } // Overhead percentage used
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount
|
||||
public decimal ProfitPercent { get; set; } // Profit margin percentage used
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + overhead + profit + shop supplies)
|
||||
public decimal OverheadAmount { get; set; } // Legacy overhead (now always 0; kept for migration safety)
|
||||
public decimal OverheadPercent { get; set; } // Legacy overhead percent
|
||||
public decimal ProfitMargin { get; set; } // Profit margin dollar amount (0 — baked into item prices)
|
||||
public decimal ProfitPercent { get; set; } // Markup % used (for display reference)
|
||||
public decimal SubTotal { get; set; } // SubtotalBeforeDiscount (items + oven + facility overhead + shop supplies)
|
||||
|
||||
// Discount Information
|
||||
public DiscountType DiscountType { get; set; } = DiscountType.None;
|
||||
public decimal DiscountValue { get; set; } = 0; // Value entered by user (percentage or fixed amount)
|
||||
public decimal DiscountPercent { get; set; } // Calculated: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Calculated: actual dollar amount deducted
|
||||
public decimal PricingTierDiscountAmount { get; set; } // Discount from customer's pricing tier
|
||||
public decimal PricingTierDiscountPercent { get; set; } // Tier discount percentage
|
||||
public decimal QuoteDiscountAmount { get; set; } // Manual quote-level discount amount
|
||||
public decimal QuoteDiscountPercent { get; set; } // Manual quote-level discount percentage
|
||||
public decimal DiscountPercent { get; set; } // Combined: actual percentage applied
|
||||
public decimal DiscountAmount { get; set; } // Combined: actual dollar amount deducted
|
||||
public string? DiscountReason { get; set; } // Why discount was applied
|
||||
public bool HideDiscountFromCustomer { get; set; } = false; // Show only total on PDFs/portal
|
||||
public decimal SubtotalAfterDiscount { get; set; } // SubTotal minus all discounts, before rush/tax
|
||||
|
||||
public decimal TaxPercent { get; set; }
|
||||
public decimal TaxAmount { get; set; }
|
||||
|
||||
@@ -33,6 +33,13 @@ public class QuoteItemCoat : BaseEntity
|
||||
public decimal CoatLaborCost { get; set; }
|
||||
public decimal CoatTotalCost { get; set; }
|
||||
|
||||
// Pricing flags
|
||||
/// <summary>
|
||||
/// When true, the additional layer labor charge is not applied for this coat even if it is
|
||||
/// not the first coat in the sequence. Used for clear coats, sealers, etc.
|
||||
/// </summary>
|
||||
public bool NoExtraLayerCharge { get; set; }
|
||||
|
||||
// Notes
|
||||
public string? Notes { get; set; }
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ public class Refund : BaseEntity
|
||||
public DateTime? IssuedDate { get; set; }
|
||||
public string? IssuedById { get; set; }
|
||||
|
||||
/// <summary>Bank/checking account the refund was paid from. Mirrors Payment.DepositAccountId so
|
||||
/// the Trial Balance can credit this account when computing bank balance.</summary>
|
||||
public int? DepositAccountId { get; set; }
|
||||
|
||||
// For store-credit refunds: the CreditMemo created on their behalf
|
||||
public int? CreditMemoId { get; set; }
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
public class ShopWorker : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public ShopWorkerRole Role { get; set; } = ShopWorkerRole.GeneralLabor;
|
||||
public string? Phone { get; set; }
|
||||
public string? Email { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
public string? Notes { get; set; }
|
||||
|
||||
// Relationships
|
||||
public virtual ICollection<Job> AssignedJobs { get; set; } = new List<Job>();
|
||||
public virtual ICollection<MaintenanceRecord> AssignedMaintenanceTasks { get; set; } = new List<MaintenanceRecord>();
|
||||
public virtual ICollection<JobTimeEntry> TimeEntries { get; set; } = new List<JobTimeEntry>();
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using PowderCoating.Core.Enums;
|
||||
|
||||
namespace PowderCoating.Core.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// Optional per-role labor cost rate for job costing / profitability calculations.
|
||||
/// If no rate is set for a role, the company's StandardLaborRate is used as fallback.
|
||||
/// </summary>
|
||||
public class ShopWorkerRoleCost : BaseEntity
|
||||
{
|
||||
public ShopWorkerRole Role { get; set; }
|
||||
|
||||
/// <summary>Cost (pay rate) per hour for this role — used in job costing, NOT billing.</summary>
|
||||
public decimal HourlyRate { get; set; }
|
||||
}
|
||||
@@ -35,6 +35,10 @@ public class Vendor : BaseEntity
|
||||
/// <summary>Default expense account pre-filled on new bill line items for this vendor.</summary>
|
||||
public int? DefaultExpenseAccountId { get; set; }
|
||||
|
||||
// 1099 Contractor tracking
|
||||
/// <summary>When true, this vendor is an independent contractor subject to 1099-NEC reporting.</summary>
|
||||
public bool Is1099Vendor { get; set; } = false;
|
||||
|
||||
// Navigation
|
||||
public virtual ICollection<InventoryItem> InventoryItems { get; set; } = new List<InventoryItem>();
|
||||
public virtual ICollection<Bill> Bills { get; set; } = new List<Bill>();
|
||||
|
||||
@@ -66,3 +66,61 @@ public enum BillStatus
|
||||
Paid = 3,
|
||||
Voided = 4
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Company-level accounting method preference. Affects how financial reports
|
||||
/// (P&L, Balance Sheet, Cash Flow) query and present data. Switching this
|
||||
/// setting never re-posts historical GL entries — it is a report-time choice only.
|
||||
/// </summary>
|
||||
public enum AccountingMethod
|
||||
{
|
||||
/// <summary>Revenue and expenses recognised when cash changes hands.</summary>
|
||||
Cash = 0,
|
||||
/// <summary>Revenue and expenses recognised when earned/incurred (default).</summary>
|
||||
Accrual = 1
|
||||
}
|
||||
|
||||
public enum BankReconciliationStatus
|
||||
{
|
||||
InProgress = 0,
|
||||
Completed = 1
|
||||
}
|
||||
|
||||
public enum VendorCreditStatus
|
||||
{
|
||||
Open = 0,
|
||||
PartiallyApplied = 1,
|
||||
Applied = 2,
|
||||
Voided = 3
|
||||
}
|
||||
|
||||
/// <summary>Source document type for a recurring template — controls which entity is created on each fire.</summary>
|
||||
public enum RecurringTemplateType
|
||||
{
|
||||
/// <summary>Creates a vendor Bill (Draft, pending user review).</summary>
|
||||
Bill = 1,
|
||||
/// <summary>Creates a direct Expense entry (immediately recorded).</summary>
|
||||
Expense = 2
|
||||
}
|
||||
|
||||
/// <summary>How often a recurring template fires.</summary>
|
||||
public enum RecurringFrequency
|
||||
{
|
||||
Daily = 1,
|
||||
Weekly = 2,
|
||||
BiWeekly = 3,
|
||||
Monthly = 4,
|
||||
Quarterly = 5,
|
||||
Annually = 6
|
||||
}
|
||||
|
||||
/// <summary>Lifecycle state of a Manual Journal Entry.</summary>
|
||||
public enum JournalEntryStatus
|
||||
{
|
||||
/// <summary>Not yet posted — can still be edited or deleted.</summary>
|
||||
Draft = 0,
|
||||
/// <summary>Posted to the GL — immutable; can only be reversed.</summary>
|
||||
Posted = 1,
|
||||
/// <summary>A reversal JE has been created and posted for this entry.</summary>
|
||||
Reversed = 2
|
||||
}
|
||||
|
||||
@@ -78,17 +78,6 @@ public enum EquipmentStatus
|
||||
Retired = 4
|
||||
}
|
||||
|
||||
public enum ShopWorkerRole
|
||||
{
|
||||
GeneralLabor = 0,
|
||||
Sandblaster = 1,
|
||||
Coater = 2,
|
||||
Masker = 3,
|
||||
QualityControl = 4,
|
||||
OvenOperator = 5,
|
||||
Supervisor = 6,
|
||||
Maintenance = 7
|
||||
}
|
||||
|
||||
public enum JobPhotoType
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace PowderCoating.Core.Enums;
|
||||
|
||||
public enum KioskSessionType
|
||||
{
|
||||
InPerson = 0,
|
||||
Remote = 1
|
||||
}
|
||||
|
||||
public enum KioskSessionStatus
|
||||
{
|
||||
Active = 0,
|
||||
Submitted = 1,
|
||||
Expired = 2,
|
||||
Cancelled = 3
|
||||
}
|
||||
@@ -20,5 +20,7 @@ public enum NotificationType
|
||||
SmsInboundStop = 12,
|
||||
SmsInboundHelp = 13,
|
||||
AdminEmail = 14,
|
||||
SmsInboundStart = 15
|
||||
SmsInboundStart = 15,
|
||||
AppointmentReminder = 17,
|
||||
AppointmentReminderStaff = 18
|
||||
}
|
||||
|
||||
@@ -54,8 +54,6 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<AppointmentStatusLookup> AppointmentStatusLookups { get; }
|
||||
IRepository<AppointmentTypeLookup> AppointmentTypeLookups { get; }
|
||||
IRepository<PrepService> PrepServices { get; }
|
||||
IRepository<ShopWorker> ShopWorkers { get; }
|
||||
IRepository<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; }
|
||||
IRepository<ReworkRecord> ReworkRecords { get; }
|
||||
IRepository<Refund> Refunds { get; }
|
||||
IRepository<CreditMemo> CreditMemos { get; }
|
||||
@@ -91,6 +89,35 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<BillPayment> BillPayments { get; }
|
||||
IRepository<Expense> Expenses { get; }
|
||||
|
||||
// Manual Journal Entries
|
||||
IRepository<JournalEntry> JournalEntries { get; }
|
||||
IRepository<JournalEntryLine> JournalEntryLines { get; }
|
||||
|
||||
// Vendor Credits
|
||||
IRepository<VendorCredit> VendorCredits { get; }
|
||||
IRepository<VendorCreditLineItem> VendorCreditLineItems { get; }
|
||||
IRepository<VendorCreditApplication> VendorCreditApplications { get; }
|
||||
|
||||
// Bank Reconciliation
|
||||
IRepository<BankReconciliation> BankReconciliations { get; }
|
||||
|
||||
// Tax Rates
|
||||
IRepository<TaxRate> TaxRates { get; }
|
||||
|
||||
// Recurring Transactions
|
||||
IRepository<RecurringTemplate> RecurringTemplates { get; }
|
||||
|
||||
// Fixed Assets
|
||||
IRepository<FixedAsset> FixedAssets { get; }
|
||||
IRepository<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; }
|
||||
|
||||
// Budgeting
|
||||
IRepository<Budget> Budgets { get; }
|
||||
IRepository<BudgetLine> BudgetLines { get; }
|
||||
|
||||
// Year-End Close
|
||||
IRepository<YearEndClose> YearEndCloses { get; }
|
||||
|
||||
// Notifications — typed repository for IgnoreQueryFilters-based history lookups
|
||||
INotificationLogRepository NotificationLogs { get; }
|
||||
IRepository<NotificationTemplate> NotificationTemplates { get; }
|
||||
@@ -125,6 +152,9 @@ public interface IUnitOfWork : IDisposable
|
||||
IRepository<GiftCertificate> GiftCertificates { get; }
|
||||
IRepository<GiftCertificateRedemption> GiftCertificateRedemptions { get; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
IRepository<KioskSession> KioskSessions { get; }
|
||||
|
||||
Task<int> SaveChangesAsync();
|
||||
Task<int> CompleteAsync(); // Alias for SaveChangesAsync
|
||||
|
||||
|
||||
@@ -85,4 +85,11 @@ public interface IJobRepository : IRepository<Job>
|
||||
/// Returns null if not found or soft-deleted.
|
||||
/// </summary>
|
||||
Task<Job?> LoadForTemplateSnapshotAsync(int jobId);
|
||||
|
||||
/// <summary>
|
||||
/// Returns all non-terminal jobs whose <c>ScheduledDate</c> is before today and not null,
|
||||
/// ordered by scheduled date then job number. Used by the Daily Board to surface jobs that
|
||||
/// were never completed and rolled past their scheduled day.
|
||||
/// </summary>
|
||||
Task<List<Job>> GetOverdueScheduledJobsAsync();
|
||||
}
|
||||
|
||||
@@ -9,12 +9,17 @@ public record CompanyWizardInfo(bool Completed, DateTime? CompletedAt, string? C
|
||||
|
||||
/// <summary>
|
||||
/// Per-company entity count summary used to populate the Index list without N+1 round-trips.
|
||||
/// Also carries health-signal data (jobs30, jobs90, last login) so callers can compute a
|
||||
/// <c>ChurnRisk</c> badge without a separate round-trip.
|
||||
/// </summary>
|
||||
public record CompanyCountSummary(
|
||||
IReadOnlyDictionary<int, int> JobCounts,
|
||||
IReadOnlyDictionary<int, int> QuoteCounts,
|
||||
IReadOnlyDictionary<int, int> CustomerCounts,
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo
|
||||
IReadOnlyDictionary<int, CompanyWizardInfo> WizardInfo,
|
||||
IReadOnlyDictionary<int, int> Jobs30Counts,
|
||||
IReadOnlyDictionary<int, int> Jobs90Counts,
|
||||
IReadOnlyDictionary<int, DateTime?> LastLoginDates
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
@@ -26,10 +31,13 @@ public interface ICompanyListService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a paged, searched, and sorted slice of non-deleted companies together with the
|
||||
/// total unfiltered count for pagination.
|
||||
/// total count for pagination and the count of churned accounts that are currently hidden.
|
||||
/// When <paramref name="hideChurned"/> is true, Expired/Canceled companies whose subscription
|
||||
/// ended more than 14 days ago are excluded from results (but still counted for the banner).
|
||||
/// </summary>
|
||||
Task<(List<Company> Companies, int TotalCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize);
|
||||
Task<(List<Company> Companies, int TotalCount, int ChurnedCount)> GetPagedAsync(
|
||||
string? searchTerm, string sortColumn, string sortDirection, int page, int pageSize,
|
||||
bool hideChurned = true);
|
||||
|
||||
/// <summary>
|
||||
/// Returns job, quote, customer, and wizard completion counts for each of the supplied
|
||||
|
||||
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||||
return companyId;
|
||||
|
||||
return null;
|
||||
// Authenticated but CompanyId claim is missing or invalid.
|
||||
// Return 0 (never a real company ID) so the global filter generates
|
||||
// "CompanyId = 0" which matches nothing — prevents null-comparison
|
||||
// ambiguity from leaking cross-tenant rows.
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
{
|
||||
get
|
||||
{
|
||||
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
|
||||
if (_httpContextAccessor?.HttpContext == null) return true;
|
||||
if (!IsSuperAdmin) return false;
|
||||
return CurrentCompanyId == null || CurrentCompanyId == 1;
|
||||
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
|
||||
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,10 +212,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
||||
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Vendor> Vendors { get; set; }
|
||||
/// <summary>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<ShopWorker> ShopWorkers { get; set; }
|
||||
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
|
||||
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { get; set; }
|
||||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
||||
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
||||
@@ -324,6 +327,39 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Expense> Expenses { get; set; }
|
||||
|
||||
/// <summary>Manual double-entry journal entries (Draft/Posted/Reversed lifecycle); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<JournalEntry> JournalEntries { get; set; }
|
||||
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
|
||||
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
|
||||
|
||||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||||
|
||||
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<TaxRate> TaxRates { get; set; }
|
||||
|
||||
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
|
||||
|
||||
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<FixedAsset> FixedAssets { get; set; }
|
||||
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
|
||||
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
|
||||
|
||||
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<Budget> Budgets { get; set; }
|
||||
/// <summary>One row per account per Budget; contains Jan–Dec decimal columns.</summary>
|
||||
public DbSet<BudgetLine> BudgetLines { get; set; }
|
||||
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
|
||||
public DbSet<YearEndClose> YearEndCloses { get; set; }
|
||||
|
||||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||||
public DbSet<VendorCreditLineItem> VendorCreditLineItems { get; set; }
|
||||
/// <summary>Application records linking a vendor credit to a specific bill; soft-delete only.</summary>
|
||||
public DbSet<VendorCreditApplication> VendorCreditApplications { get; set; }
|
||||
|
||||
// Job Templates
|
||||
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
|
||||
public DbSet<JobTemplate> JobTemplates { get; set; }
|
||||
@@ -334,6 +370,10 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
/// <summary>Prep-service definitions within a job template item.</summary>
|
||||
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
||||
|
||||
// Customer Intake Kiosk
|
||||
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||||
public DbSet<KioskSession> KioskSessions { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||||
/// No global query filter — SuperAdmin controllers query this directly.
|
||||
@@ -493,10 +533,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ShopWorker>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ShopWorkerRoleCost>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
||||
@@ -614,6 +650,93 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Journal Entries: tenant-filtered; lines use soft-delete only (child rows)
|
||||
modelBuilder.Entity<JournalEntry>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// Bank Reconciliation: tenant-filtered
|
||||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Tax Rates: tenant-filtered
|
||||
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Recurring Templates: tenant-filtered
|
||||
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
|
||||
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
|
||||
// reverse collection for FixedAssets so WithMany() is anonymous for each.
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AssetAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AssetAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.DepreciationExpenseAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
modelBuilder.Entity<FixedAsset>()
|
||||
.HasOne(fa => fa.AccumDepreciationAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
|
||||
modelBuilder.Entity<FixedAssetDepreciationEntry>()
|
||||
.HasOne(e => e.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Budgets: tenant-filtered; BudgetLines soft-delete only
|
||||
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
|
||||
modelBuilder.Entity<BudgetLine>()
|
||||
.HasOne(bl => bl.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(bl => bl.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// YearEndClose: tenant-filtered; links to a specific JE
|
||||
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<YearEndClose>()
|
||||
.HasOne(y => y.JournalEntry)
|
||||
.WithMany()
|
||||
.HasForeignKey(y => y.JournalEntryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||||
|
||||
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
|
||||
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.Bill)
|
||||
.WithMany()
|
||||
.HasForeignKey(vca => vca.BillId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
modelBuilder.Entity<VendorCreditApplication>()
|
||||
.HasOne(vca => vca.VendorCredit)
|
||||
.WithMany(vc => vc.Applications)
|
||||
.HasForeignKey(vca => vca.VendorCreditId)
|
||||
.OnDelete(DeleteBehavior.NoAction);
|
||||
|
||||
// Purchase Orders
|
||||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
@@ -626,6 +749,24 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
|
||||
// Customer intake kiosk sessions — tenant-filtered + soft delete.
|
||||
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
|
||||
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
|
||||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasIndex(e => e.SessionToken)
|
||||
.IsUnique();
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasOne(k => k.LinkedCustomer)
|
||||
.WithMany()
|
||||
.HasForeignKey(k => k.LinkedCustomerId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
modelBuilder.Entity<KioskSession>()
|
||||
.HasOne(k => k.LinkedJob)
|
||||
.WithMany()
|
||||
.HasForeignKey(k => k.LinkedJobId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// Account self-referencing hierarchy
|
||||
modelBuilder.Entity<Account>()
|
||||
.HasOne(a => a.ParentAccount)
|
||||
@@ -633,6 +774,34 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(a => a.ParentAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// JournalEntry self-referencing reversal link
|
||||
modelBuilder.Entity<JournalEntry>()
|
||||
.HasOne(je => je.ReversalOf)
|
||||
.WithMany()
|
||||
.HasForeignKey(je => je.ReversalOfId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// BankReconciliation → Account (no cascade)
|
||||
modelBuilder.Entity<BankReconciliation>()
|
||||
.HasOne(br => br.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(br => br.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCredit → APAccount (no cascade)
|
||||
modelBuilder.Entity<VendorCredit>()
|
||||
.HasOne(vc => vc.APAccount)
|
||||
.WithMany()
|
||||
.HasForeignKey(vc => vc.APAccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// VendorCreditLineItem → Account (nullable, no cascade)
|
||||
modelBuilder.Entity<VendorCreditLineItem>()
|
||||
.HasOne(li => li.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(li => li.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
// Vendor → DefaultExpenseAccount (no cascade)
|
||||
modelBuilder.Entity<Vendor>()
|
||||
.HasOne(s => s.DefaultExpenseAccount)
|
||||
@@ -1144,12 +1313,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.HasForeignKey(m => m.PerformedById)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
// ShopWorker relationships
|
||||
modelBuilder.Entity<ShopWorker>()
|
||||
.HasOne<Company>()
|
||||
.WithMany(c => c.ShopWorkers)
|
||||
.HasForeignKey(e => e.CompanyId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
|
||||
modelBuilder.Entity<Job>()
|
||||
.HasOne(j => j.AssignedUser)
|
||||
@@ -1223,9 +1387,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
modelBuilder.Entity<PricingTier>()
|
||||
.HasIndex(p => p.CompanyId);
|
||||
|
||||
modelBuilder.Entity<ShopWorker>()
|
||||
.HasIndex(w => w.CompanyId);
|
||||
|
||||
modelBuilder.Entity<CatalogCategory>()
|
||||
.HasIndex(c => c.CompanyId);
|
||||
|
||||
@@ -1261,11 +1422,6 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
||||
|
||||
modelBuilder.Entity<ShopWorkerRoleCost>()
|
||||
.HasIndex(r => new { r.CompanyId, r.Role })
|
||||
.IsUnique()
|
||||
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
|
||||
|
||||
modelBuilder.Entity<Job>()
|
||||
.Property(j => j.ShopAccessCode)
|
||||
.HasDefaultValueSql("NEWID()");
|
||||
|
||||
@@ -21,7 +21,7 @@ public class AuditInterceptor : SaveChangesInterceptor
|
||||
private static readonly HashSet<string> AuditedTypes = new(StringComparer.Ordinal)
|
||||
{
|
||||
nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment),
|
||||
nameof(MaintenanceRecord), nameof(Vendor), nameof(ShopWorker),
|
||||
nameof(MaintenanceRecord), nameof(Vendor),
|
||||
nameof(InventoryItem), nameof(Company),
|
||||
// Financial entities
|
||||
nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment),
|
||||
|
||||
@@ -967,6 +967,17 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.InvoiceSent,
|
||||
Channel = NotificationChannel.Sms,
|
||||
DisplayName = "Invoice Sent (SMS)",
|
||||
Subject = null,
|
||||
Body = "{{companyName}}: Invoice {{invoiceNumber}} for {{invoiceTotal}} is ready. View your invoice: {{viewUrl}} Reply STOP to opt out.",
|
||||
IsActive = true,
|
||||
CompanyId = companyId,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
},
|
||||
new NotificationTemplate
|
||||
{
|
||||
NotificationType = NotificationType.PaymentReceived,
|
||||
Channel = NotificationChannel.Email,
|
||||
|
||||
+10633
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,26 +11,20 @@ namespace PowderCoating.Infrastructure.Migrations
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7851));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7856));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 3, 16, 15, 49, 58, 737, DateTimeKind.Utc).AddTicks(7858));
|
||||
// These UpdateData calls were generated from an existing live database.
|
||||
// On a fresh install the PricingTiers table and its seed rows may not exist yet
|
||||
// (seeding is manual via Platform Management → Seed Data), so guard each update.
|
||||
migrationBuilder.Sql(@"
|
||||
IF OBJECT_ID(N'[PricingTiers]', N'U') IS NOT NULL
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 1)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377851Z' WHERE [Id] = 1;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 2)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377856Z' WHERE [Id] = 2;
|
||||
IF EXISTS (SELECT 1 FROM [PricingTiers] WHERE [Id] = 3)
|
||||
UPDATE [PricingTiers] SET [CreatedAt] = '2026-03-16T15:49:58.7377858Z' WHERE [Id] = 3;
|
||||
END
|
||||
");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
src/PowderCoating.Infrastructure/Migrations/20260510011252_AddJobTemplateItemSalesFields.Designer.cs
Generated
+9552
File diff suppressed because it is too large
Load Diff
+82
@@ -0,0 +1,82 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobTemplateItemSalesFields : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems",
|
||||
type: "nvarchar(max)",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsSalesItem",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Sku",
|
||||
table: "JobTemplateItems");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4358));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4424));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 8, 14, 21, 51, 589, DateTimeKind.Utc).AddTicks(4426));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+9555
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAccountingMethod : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 1); // 1 = Accrual (default for new and existing companies)
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AccountingMethod",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2249));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2260));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 1, 12, 48, 386, DateTimeKind.Utc).AddTicks(2261));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9715
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJournalEntries : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
EntryNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
EntryDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Reference = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
IsReversal = table.Column<bool>(type: "bit", nullable: false),
|
||||
ReversalOfId = table.Column<int>(type: "int", nullable: true),
|
||||
PostedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
PostedBy = 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_JournalEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntries_JournalEntries_ReversalOfId",
|
||||
column: x => x.ReversalOfId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JournalEntryLines",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
JournalEntryId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
DebitAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CreditAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
LineOrder = table.Column<int>(type: "int", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JournalEntryLines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_JournalEntryLines_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntries_ReversalOfId",
|
||||
table: "JournalEntries",
|
||||
column: "ReversalOfId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_AccountId",
|
||||
table: "JournalEntryLines",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JournalEntryLines_JournalEntryId",
|
||||
table: "JournalEntryLines",
|
||||
column: "JournalEntryId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntryLines");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JournalEntries");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9957));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9963));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 25, 9, 644, DateTimeKind.Utc).AddTicks(9965));
|
||||
}
|
||||
}
|
||||
}
|
||||
+9951
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,212 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddVendorCredits : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCredits",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
CreditNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
VendorId = table.Column<int>(type: "int", nullable: false),
|
||||
APAccountId = table.Column<int>(type: "int", nullable: false),
|
||||
CreditDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
Total = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
RemainingAmount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Memo = 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_VendorCredits", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Accounts_APAccountId",
|
||||
column: x => x.APAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCredits_Vendors_VendorId",
|
||||
column: x => x.VendorId,
|
||||
principalTable: "Vendors",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditApplications",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
BillId = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
AppliedDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCreditApplications", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
column: x => x.BillId,
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.NoAction);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "VendorCreditLineItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
VendorCreditId = table.Column<int>(type: "int", nullable: false),
|
||||
AccountId = table.Column<int>(type: "int", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_VendorCreditLineItems", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_VendorCreditLineItems_VendorCredits_VendorCreditId",
|
||||
column: x => x.VendorCreditId,
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_AccountId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "AccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditLineItems_VendorCreditId",
|
||||
table: "VendorCreditLineItems",
|
||||
column: "VendorCreditId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_APAccountId",
|
||||
table: "VendorCredits",
|
||||
column: "APAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCredits_VendorId",
|
||||
table: "VendorCredits",
|
||||
column: "VendorId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCreditLineItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "VendorCredits");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9350));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9357));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 45, 31, 524, DateTimeKind.Utc).AddTicks(9359));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10043
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBankReconciliation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Payments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Payments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "Expenses",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BankReconciliations",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
AccountId = table.Column<int>(type: "int", nullable: false),
|
||||
StatementDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
BeginningBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
EndingBalance = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
Status = table.Column<int>(type: "int", nullable: false),
|
||||
CompletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CompletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Notes = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BankReconciliations", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_BankReconciliations_Accounts_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BankReconciliations_AccountId",
|
||||
table: "BankReconciliations",
|
||||
column: "AccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BankReconciliations");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Payments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "Expenses");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ClearedDate",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCleared",
|
||||
table: "BillPayments");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(6994));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7001));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 3, 58, 27, 360, DateTimeKind.Utc).AddTicks(7003));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10105
File diff suppressed because it is too large
Load Diff
+112
@@ -0,0 +1,112 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPaymentTermsAndTaxRates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices",
|
||||
type: "decimal(18,2)",
|
||||
nullable: false,
|
||||
defaultValue: 0m);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "TaxRates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Rate = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
State = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDefault = table.Column<bool>(type: "bit", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
CompanyId = table.Column<int>(type: "int", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
CreatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
UpdatedBy = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
IsDeleted = table.Column<bool>(type: "bit", nullable: false),
|
||||
DeletedAt = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
DeletedBy = table.Column<string>(type: "nvarchar(max)", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_TaxRates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "TaxRates");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountDays",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "EarlyPaymentDiscountPercent",
|
||||
table: "Invoices");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8472));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8478));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 4, 6, 6, 200, DateTimeKind.Utc).AddTicks(8479));
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10186
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRecurringTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RecurringTemplates",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
TemplateType = table.Column<int>(type: "int", nullable: false),
|
||||
Frequency = table.Column<int>(type: "int", nullable: false),
|
||||
IntervalCount = table.Column<int>(type: "int", nullable: false),
|
||||
NextFireDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
EndDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
MaxOccurrences = table.Column<int>(type: "int", nullable: true),
|
||||
OccurrenceCount = table.Column<int>(type: "int", nullable: false),
|
||||
IsActive = table.Column<bool>(type: "bit", nullable: false),
|
||||
TemplateData = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
LastError = 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_RecurringTemplates", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId",
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RecurringTemplates");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3903));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3909));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 14, 48, 51, 545, DateTimeKind.Utc).AddTicks(3910));
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_Bills_BillId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "BillId",
|
||||
principalTable: "Bills",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10177
File diff suppressed because it is too large
Load Diff
+91
@@ -0,0 +1,91 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DropOrphanVendorCreditId1 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
type: "int",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6262));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6270));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 1, 54, 769, DateTimeKind.Utc).AddTicks(6271));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_VendorCreditApplications_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_VendorCreditApplications_VendorCredits_VendorCreditId1",
|
||||
table: "VendorCreditApplications",
|
||||
column: "VendorCreditId1",
|
||||
principalTable: "VendorCredits",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+10366
File diff suppressed because it is too large
Load Diff
+199
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace PowderCoating.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddFixedAssetsLockAnd1099 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors",
|
||||
type: "bit",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies",
|
||||
type: "datetime2",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssets",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
PurchaseDate = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
PurchaseCost = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
SalvageValue = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
UsefulLifeMonths = table.Column<int>(type: "int", nullable: false),
|
||||
AccumulatedDepreciation = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
IsDisposed = table.Column<bool>(type: "bit", nullable: false),
|
||||
DisposalDate = table.Column<DateTime>(type: "datetime2", nullable: true),
|
||||
AssetAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
DepreciationExpenseAccountId = table.Column<int>(type: "int", nullable: true),
|
||||
AccumDepreciationAccountId = table.Column<int>(type: "int", 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_FixedAssets", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AccumDepreciationAccountId",
|
||||
column: x => x.AccumDepreciationAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_AssetAccountId",
|
||||
column: x => x.AssetAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssets_Accounts_DepreciationExpenseAccountId",
|
||||
column: x => x.DepreciationExpenseAccountId,
|
||||
principalTable: "Accounts",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "FixedAssetDepreciationEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "int", nullable: false)
|
||||
.Annotation("SqlServer:Identity", "1, 1"),
|
||||
FixedAssetId = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodYear = table.Column<int>(type: "int", nullable: false),
|
||||
PeriodMonth = table.Column<int>(type: "int", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
|
||||
JournalEntryId = table.Column<int>(type: "int", 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_FixedAssetDepreciationEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_FixedAssets_FixedAssetId",
|
||||
column: x => x.FixedAssetId,
|
||||
principalTable: "FixedAssets",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_FixedAssetDepreciationEntries_JournalEntries_JournalEntryId",
|
||||
column: x => x.JournalEntryId,
|
||||
principalTable: "JournalEntries",
|
||||
principalColumn: "Id");
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4004));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4009));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 16, 0, 49, 626, DateTimeKind.Utc).AddTicks(4011));
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_FixedAssetId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "FixedAssetId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssetDepreciationEntries_JournalEntryId",
|
||||
table: "FixedAssetDepreciationEntries",
|
||||
column: "JournalEntryId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AccumDepreciationAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AccumDepreciationAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_AssetAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "AssetAccountId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_FixedAssets_DepreciationExpenseAccountId",
|
||||
table: "FixedAssets",
|
||||
column: "DepreciationExpenseAccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssetDepreciationEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "FixedAssets");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Is1099Vendor",
|
||||
table: "Vendors");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "BookLockedThrough",
|
||||
table: "Companies");
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 1,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(199));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 2,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(205));
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "PricingTiers",
|
||||
keyColumn: "Id",
|
||||
keyValue: 3,
|
||||
column: "CreatedAt",
|
||||
value: new DateTime(2026, 5, 10, 15, 25, 26, 831, DateTimeKind.Utc).AddTicks(206));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user