Compare commits
49 Commits
v2026.06.16
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| c73dc08b92 | |||
| 06a5070a92 | |||
| 27cf4532cf | |||
| 4df85d75db | |||
| f9039fc735 | |||
| f804906481 | |||
| c0d3a30176 | |||
| 7c0357b4c5 | |||
| 774f916dae | |||
| 74d529f7d2 | |||
| 58a0010ae8 | |||
| ee86d7aaf6 | |||
| 687aedf7a4 | |||
| 8b9a3dff41 | |||
| 57ec3ed127 | |||
| 012f4d9a3e | |||
| 7834d67432 | |||
| 91ed19c2b1 | |||
| 08a5cd39d4 | |||
| df194bd64b | |||
| 7576761b70 | |||
| 517e452c64 | |||
| 1005be0c9e | |||
| 9532812b9f | |||
| 0c921ba180 | |||
| c2cd19e475 | |||
| 9ce361235f | |||
| 2a82a1d34b | |||
| f54b945053 | |||
| f752abad86 | |||
| 148a3f465e | |||
| a6538d9638 | |||
| 059d94d4fe | |||
| 8401bd77e8 | |||
| 0f6eef5370 | |||
| c22537b68f | |||
| 115ccf7d5e | |||
| 99b22d2ad2 | |||
| 6db055dcf8 | |||
| eed61a298b | |||
| 2286b5431d | |||
| d2d9f44358 | |||
| 4506c1f641 | |||
| a07f6aa1a8 | |||
| 9aa3a99488 | |||
| 2b420d4623 | |||
| a4a3dde7e4 | |||
| 39f61b9718 | |||
| c98f9faf63 |
@@ -0,0 +1,285 @@
|
|||||||
|
# Accounting System Audit
|
||||||
|
|
||||||
|
> Living record of the accounting/GL audit and its remediation status.
|
||||||
|
> **Keep this file updated** whenever an accounting finding is opened or closed — the
|
||||||
|
> original audit was lost once because it was never written down. Don't let that happen again.
|
||||||
|
|
||||||
|
Last reconciled against code: **2026-06-19** (branch `dev`, commits through `9532812`).
|
||||||
|
Verification at that point: `dotnet build` clean (0 errors); `dotnet test tests/PowderCoating.UnitTests` → **290 passed, 0 failed**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the GL is modeled (context for the findings)
|
||||||
|
|
||||||
|
Balances are computed two different ways, and they must agree:
|
||||||
|
|
||||||
|
1. **At posting time** — controllers call `AccountBalanceService.DebitAsync/CreditAsync`, which
|
||||||
|
mutate the denormalized `Account.CurrentBalance`.
|
||||||
|
2. **At report time** — `FinancialReportService` (Trial Balance, Balance Sheet, P&L, AR/AP aging,
|
||||||
|
statements) and `LedgerService` (per-account ledger + `RecalculateBalances`) recompute balances
|
||||||
|
from source documents.
|
||||||
|
|
||||||
|
`LedgerService` also backs the **Balance Reconciliation report** (the detective control added in
|
||||||
|
`c2cd19e`), which compares stored `CurrentBalance` vs the ledger-recomputed balance. Any account
|
||||||
|
whose recompute path is incomplete will (a) be silently corrupted by a "Recalculate Balances" run
|
||||||
|
and (b) produce misleading output in the reconciliation report.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved findings (this audit batch)
|
||||||
|
|
||||||
|
| # | Finding | Fix | Commit | Verified |
|
||||||
|
|---|---------|-----|--------|----------|
|
||||||
|
| **#4** | `Sales Tax Payable` (2200) was credited on every invoice but never relieved — the liability grew forever (no remittance flow). | `JournalEntries/SalesTaxPayment` GET+POST: DR 2200 / CR bank, balanced, in a transaction, honors the period lock and validates amount + bank-account tenancy. | `0c921ba` | Posting reviewed `JournalEntriesController:235-296`. Reporting needs no change (posted JE lines already flow through TB/BS/ledger). |
|
||||||
|
| **#7** | Most report queries relied on the global tenant filter, which is **bypassed for SuperAdmin** → cross-company leakage on P&L / Balance Sheet / Trial Balance / aging / statements. | Explicit `CompanyId == companyId` predicate added to every query in `FinancialReportService`. | `9532812` | Confirmed: every query in `FinancialReportService.cs` carries an explicit `CompanyId` predicate. |
|
||||||
|
| **#9a** | A cash refund posted **DR AR** (control up) while the customer subledger went down — opposite directions → guaranteed AR drift. | Refund now **reverses the sale**: DR Sales Returns (4960) + DR Sales Tax Payable (tax portion) / CR bank; AR untouched. Split centralized in `Core/Accounting/RefundAllocation.Split`. | `2a82a1d` | Posting (`InvoicesController.IssueRefund`/`CancelRefund`) and both recompute paths (`LedgerService` §5b, `FinancialReportService` TB/BS/P&L) all use `RefundAllocation.Split` — they agree by construction. |
|
||||||
|
| **#9b** | Store-credit refunds & credit memos posted nothing to the GL on issue (only a `CreditMemo` + `Customer.CreditBalance`) → outstanding store credit invisible on the balance sheet. | New **2350 Customer Credits** liability. Issue: DR 4950 / CR 2350. Apply: DR 2350 / CR AR. Void remainder: DR 2350 / CR 4950. Updated in all 8 posting sites + TB/BS/P&L + ledger §12b. | `9ce3612` | Posting sites confirmed (`CreditMemosController` 180-185/262-270/314-320; `InvoicesController` store-credit path). Reporting `cmContraRevenue`/`cmIssuedNonVoided` math reviewed. |
|
||||||
|
| — | No detective control to catch denormalized-balance drift. | **Balance Reconciliation report** (`Reports/Reconciliation`): per-account stored vs recomputed, plus AR/AP control-vs-subledger. | `c2cd19e` | Reviewed `FinancialReportService.GetBalanceReconciliationAsync`. NOTE: its usefulness is limited by O2 below. |
|
||||||
|
|
||||||
|
### Original numbering gap
|
||||||
|
The commits reference findings **#4, #7, #9a, #9b** — implying a list of at least #1–#9. The original
|
||||||
|
audit document was never persisted and is unrecoverable. Findings **#1, #2, #3, #5, #6, #8** cannot be
|
||||||
|
matched to commits and their status is **unknown**. If the original list resurfaces, reconcile it here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resolved findings (2026-06-19 remediation)
|
||||||
|
|
||||||
|
### O1 — Account 2300 mislabeled "Payroll Liabilities" while used as Customer Deposits — **RESOLVED**
|
||||||
|
- **Root cause refined:** there is no payroll feature posting to 2300; the deposit GL code has always used
|
||||||
|
2300 (resolved by number) as the Customer Deposits liability. Migration `AccountingDepositsGL` already
|
||||||
|
renamed it to "Customer Deposits" for tenants that lacked a 2300, but its `NOT EXISTS` guard **skipped**
|
||||||
|
pre-existing tenants, leaving them mislabeled. The two seed files still created new tenants with the
|
||||||
|
wrong name.
|
||||||
|
- **Fix (chosen approach: rename 2300, move payroll to a new 2400):**
|
||||||
|
- Seed files now create **2300 = "Customer Deposits" (IsSystem)** and a separate **2400 = "Payroll
|
||||||
|
Liabilities"** — `SeedDataService.Accounts.cs`, `SeedData.cs`.
|
||||||
|
- `EnsureSystemAccountsAsync` self-heals: renames any 2300 still named "Payroll Liabilities" → "Customer
|
||||||
|
Deposits" (preserving user renames) and ensures 2400 exists.
|
||||||
|
- Migration `20260619233108_RenameDepositsAccountAddPayroll` does the same for existing tenants (rename
|
||||||
|
only where Name is still the default; insert 2400 where missing). Down is best-effort (soft-deletes
|
||||||
|
only empty 2400s; does not re-introduce the mislabel).
|
||||||
|
- Account **number 2300 is unchanged**, so the deposit posting code (`DepositsController`,
|
||||||
|
`InvoicesController`) needed no changes — its lookups are by `"2300"`.
|
||||||
|
|
||||||
|
### O2 — `LedgerService` never recomputed 4950 Sales Discounts → recalc corrupted it — **RESOLVED**
|
||||||
|
- **Fix:** added a 4950 section to both `LedgerService.GetAccountLedgerAsync` and `ComputePriorBalanceAsync`
|
||||||
|
that reproduces the *actual postings* so a balance recalc matches the stored balance:
|
||||||
|
invoice discounts (DR at invoice date) + credit-memo issuance (DR full amount at issue) − the unapplied
|
||||||
|
remainder of voided memos (CR at void). This mirrors what `AccountBalanceService` posts at
|
||||||
|
`InvoicesController:849/1108/1629` and `CreditMemosController`, so the Balance Reconciliation report no
|
||||||
|
longer shows false drift on 4950.
|
||||||
|
- **Note / micro-discrepancy:** the ledger uses `memo.AmountApplied` for the voided remainder (matching the
|
||||||
|
true posting), whereas `FinancialReportService` TB/BS derive the voided portion from applications against
|
||||||
|
non-voided invoices. These differ only in the rare case of a voided memo applied to a since-voided
|
||||||
|
invoice. Left as-is (report-side nuance, not O2's target); documented here so it isn't mistaken for a bug.
|
||||||
|
- Regression test: `LedgerServiceTests.GetAccountLedgerAsync_SalesDiscounts4950_IncludesInvoiceDiscountsAndCreditMemoContraRevenue`.
|
||||||
|
|
||||||
|
Verification of O1+O2: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**;
|
||||||
|
migration applied to the dev database successfully.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### O3 — Write-path account lookups omitted explicit `CompanyId` — **RESOLVED**
|
||||||
|
- Added `CompanyId` to every **GL posting-path** account lookup that determines where money posts:
|
||||||
|
- `InvoicesController` — all account-resolver helpers (`GetCheckingAccountIdAsync`,
|
||||||
|
`GetCustomerDepositsAccountIdAsync`, `GetSalesReturnsAccountIdAsync`, `GetCustomerCreditsAccountIdAsync`,
|
||||||
|
`GetArAccountIdAsync`, `GetBadDebtAccountIdAsync`, `ResolveSalesTaxAccountIdAsync`,
|
||||||
|
`GetSalesDiscountAccountIdAsync`, `GetGcLiabilityAccountIdAsync`) plus the bank-account and write-off
|
||||||
|
expense dropdowns (scoped via `_tenantContext`).
|
||||||
|
- `CreditMemosController` — Create/Apply/Void GL lookups (scoped via the in-scope `customer`/`invoice`/`memo`).
|
||||||
|
- `GiftCertificatesController` — Create, BulkCreate, Void GL lookups + `GetGcLiabilityAccountIdAsync`.
|
||||||
|
- `BillsController` — AP/expense account resolution that pre-fills `APAccountId` (Create + CreateFromPO).
|
||||||
|
- `DepositsController` and `JournalEntriesController.SalesTaxPayment` already scoped correctly.
|
||||||
|
|
||||||
|
### O4 — Sales-tax remittance could over-remit (drive 2200 negative) — **RESOLVED**
|
||||||
|
- `JournalEntriesController.SalesTaxPayment` (POST) now rejects any amount exceeding `taxAcct.CurrentBalance`
|
||||||
|
(0.005 rounding tolerance), so a typo can no longer push Sales Tax Payable into an abnormal debit balance.
|
||||||
|
|
||||||
|
Verification of O3+O4: `dotnet build` clean; `dotnet test tests/PowderCoating.UnitTests` → **291 passed**.
|
||||||
|
|
||||||
|
### O5 — Gift Certificate Liability 2500 missing for new tenants / mislabeled on default company — **RESOLVED**
|
||||||
|
- **Root cause (same shape as O1):** 2500 is resolved by number as the GC liability
|
||||||
|
(`GiftCertificatesController`). The `AccountingGapsPhase2` migration seeded it for tenants existing at
|
||||||
|
deploy, but (a) the per-tenant seeder `SeedDataService.Accounts.cs` never created a 2500, so tenants
|
||||||
|
onboarded afterward had **no GC liability account** and GC GL postings silently no-op'd; and (b) the
|
||||||
|
default-company seeder `SeedData.cs` created 2500 as **"Long-Term Loan"**, so that company's GC
|
||||||
|
obligations were mislabeled (and the migration's `NOT EXISTS` guard skipped it).
|
||||||
|
- **Fix:**
|
||||||
|
- `SeedDataService.Accounts.cs` now seeds **2500 "Gift Certificate Liability" (IsSystem)**.
|
||||||
|
- `SeedData.cs` now seeds 2500 as GC liability and moves the long-term loan to **2900**.
|
||||||
|
- `EnsureSystemAccountsAsync` self-heals: renames any 2500 still named "Long-Term Loan" → "Gift
|
||||||
|
Certificate Liability" (preserving user renames) and ensures a 2500 exists.
|
||||||
|
- Migration `20260620002950_FixGiftCertificateLiabilityAccount`: moves long-term loan to 2900 where a
|
||||||
|
2500="Long-Term Loan" exists and no 2900 is present; relabels the mislabeled 2500; safety-net inserts a
|
||||||
|
2500 for any company lacking one. Non-destructive (no Id/number/balance changes); Down is best-effort.
|
||||||
|
- Verified on the dev DB: existing 2500 GC-liability rows preserved; no spurious accounts added; build
|
||||||
|
clean; migration applied; **291 unit tests pass**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Read-path account lookup sweep (Tier 1–3) — **RESOLVED**
|
||||||
|
Completed the app-wide defense-in-depth pass: every `Accounts` lookup in a controller now carries an
|
||||||
|
explicit `CompanyId` predicate, matching the standing rule in CLAUDE.md. ~19 lookups across 12 controllers:
|
||||||
|
- **Tier 1 (write-path validation):** `AccountsController` duplicate account-number check on Create/Edit.
|
||||||
|
- **Tier 2 (dropdowns/lists):** `AccountsController` (Index/year-end/parent dropdown), `BankReconciliations`,
|
||||||
|
`Bills` (bank list + receipt-scan + suggest), `Budgets`, `CatalogItems`, `Expenses`, `FixedAssets`,
|
||||||
|
`Inventory`, `JournalEntries` (chart dropdown), `Vendors`.
|
||||||
|
- **Tier 3 (`accountIds.Contains` display maps):** `JournalEntries` Details, `Reports` budget vs actual,
|
||||||
|
`VendorCredits` Details — scoped via the in-scope entity's `CompanyId` for uniformity.
|
||||||
|
`companyId` source per controller: `_tenantContext.GetCurrentCompanyId()` where available, else the
|
||||||
|
in-scope entity's `CompanyId`, else `_userManager` current user. Build clean; 291 unit tests pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Whole-system re-audit (2026-06-19) — NEW findings, OPEN
|
||||||
|
|
||||||
|
Root cause shared by all three: the GL is kept two ways — **direct postings** via
|
||||||
|
`AccountBalanceService.Debit/CreditAsync` (authoritative `Account.CurrentBalance`) and **recomputes**
|
||||||
|
via `LedgerService` (drives `RecalculateAllAsync`) and `FinancialReportService` (drives the reports).
|
||||||
|
Every posting type must be mirrored in *both* recompute engines or balances diverge. Several postings
|
||||||
|
are **not** mirrored, so: (a) "Recalculate Balances" corrupts those accounts, (b) the Balance
|
||||||
|
Reconciliation report shows false drift, and in one case (O7) the **Trial Balance does not balance**.
|
||||||
|
O2 (Sales Discounts 4950) was the first instance found and fixed; these are the rest.
|
||||||
|
|
||||||
|
### O6 — Inventory consumption COGS not in the recompute — **RESOLVED (recompute approach)**
|
||||||
|
- `JobsController` and `InventoryController` post **DR COGS / CR Inventory** when an item with both
|
||||||
|
`CogsAccountId` and `InventoryAccountId` is consumed. The COGS posting fires only for `JobUsage` and
|
||||||
|
`Waste` transaction types, and those types are created **only** at the two COGS-posting sites — so the
|
||||||
|
consumption is exactly identifiable from `InventoryTransaction`. (This is why the recompute approach was
|
||||||
|
used instead of the originally-planned JE+backfill: it reads existing transactions, so historical data is
|
||||||
|
covered automatically with no fuzzy backfill.)
|
||||||
|
- **Fix applied:**
|
||||||
|
- Both posting sites now record the consumption at the effective (weighted-average) unit cost, so the
|
||||||
|
transaction's `TotalCost` equals the COGS actually posted (the recompute reads `TotalCost`).
|
||||||
|
- `LedgerService` (dated rows + prior balance): new section 12d — for a COGS or Inventory account, sum
|
||||||
|
`TotalCost` of JobUsage/Waste transactions on items with both accounts (DR COGS / CR Inventory).
|
||||||
|
- `FinancialReportService` **Trial Balance** (`cogsConsumptionByAcct` DR / `invConsumptionByAcct` CR) and
|
||||||
|
**P&L** (accrual COGS) include consumption. Cash-basis P&L excludes it (cash recognises cost at purchase).
|
||||||
|
- Regression test `GetAccountLedgerAsync_InventoryConsumption_DebitsCogsAndCreditsInventory`.
|
||||||
|
- **Deliberately NOT changed — see O9:** the **Balance Sheet** inventory-asset line is left as-is because it
|
||||||
|
already does not track inventory *purchases* (it expenses them through retained earnings), so relieving it
|
||||||
|
for consumption alone would drive inventory negative or unbalance the sheet. That's a separate inventory
|
||||||
|
*capitalization* policy decision (O9). TB and recalc are now correct and balanced; build clean; 293 tests pass.
|
||||||
|
|
||||||
|
### O7 — Gift-certificate redemptions credit AR but AR recompute omitted it — **RESOLVED**
|
||||||
|
- `InvoicesController.ApplyGiftCertificate:3137` posts **DR 2500 GC Liability / CR AR** (no Payment row,
|
||||||
|
no `AmountPaid` change — only `invoice.GiftCertificateRedeemed`). The **2500 debit IS** recomputed
|
||||||
|
(GC redemptions), but the **AR credit is NOT**: AR recompute = `sum(Total) − Payments − CreditMemoApplications`
|
||||||
|
in both `FinancialReportService` (BS line 358 / TB line 1129) and `LedgerService` (AR section).
|
||||||
|
- **Impact:** because only one side of the entry is recomputed, the **Trial Balance is out of balance by the
|
||||||
|
total GC redeemed**; Balance Sheet AR is overstated; recalc corrupts AR. (AR **Aging** is correct — it uses
|
||||||
|
`BalanceDue`, which includes `GiftCertificateRedeemed`.) Active for any company that redeems GCs on invoices.
|
||||||
|
- **Fix applied:** GC redemptions now subtracted from AR in both recompute engines — `FinancialReportService`
|
||||||
|
Balance Sheet (`gcRedeemedBs`) and Trial Balance (`gcRedeemedTb`), and `LedgerService` AR section + prior
|
||||||
|
balance. Mirrors the `cmApplied` treatment. Regression test
|
||||||
|
`GetAccountLedgerAsync_AR_GiftCertificateRedemption_CreditsAccountsReceivable`. Build clean; 292 tests pass.
|
||||||
|
|
||||||
|
### O8 — Written-off invoices misstated in AR recompute — **RESOLVED**
|
||||||
|
- `InvoicesController.WriteOff` already posts **DR Bad Debt / CR AR** *and creates a balanced posted
|
||||||
|
JournalEntry* — so both recompute engines already pick the entry up via their JE-line sections. The real
|
||||||
|
defect was narrower: `FinancialReportService` **excluded payments on written-off invoices** from AR credits
|
||||||
|
and bank debits (4× `Status != InvoiceStatus.WrittenOff` filters). Because the write-off JE only credits the
|
||||||
|
*unpaid balance*, excluding the earlier payments left the **paid** portion dangling as open AR (and
|
||||||
|
understated the bank). `LedgerService` had no such filter, so the two engines disagreed.
|
||||||
|
- **Fix applied:** removed all four `WrittenOff` exclusion filters in `FinancialReportService` (Balance Sheet
|
||||||
|
+ Trial Balance, both the bank-deposit and AR-credit queries). Now: invoice `Total` (debit) − payments
|
||||||
|
(credit) − write-off JE (credit) nets AR to zero, the payment counts in the bank, and bad debt is the JE
|
||||||
|
debit — trial balance balances, and the two engines agree. No backfill needed (the write-off JE already
|
||||||
|
exists for historical write-offs). AR Aging was already correct.
|
||||||
|
|
||||||
|
**Architectural note:** O2/O6/O7/O8 all stem from reports re-deriving balances from source documents in
|
||||||
|
parallel with the live postings. The durable fix is to make **every** financial event post `JournalEntry`
|
||||||
|
lines and drive all reports/recompute from those lines alone (single source of truth) — O8's write-off already
|
||||||
|
does exactly this, which is why it was the cleanest. Worth considering before the ledger grows further.
|
||||||
|
|
||||||
|
### O9 — Inventory accounting policy — **RESOLVED (policy decision: expense at purchase / periodic)**
|
||||||
|
- **Decision (owner, 2026-06-19):** materials (powder, consumables) are **expensed at purchase**, not
|
||||||
|
capitalized as inventory-for-resale — this is a service business that *uses* materials to deliver a
|
||||||
|
service, it does not sell inventory. So the **periodic** model is correct.
|
||||||
|
- **Implication — no code change needed:**
|
||||||
|
- The Balance Sheet correctly does **not** capitalize inventory (cost hits the P&L at purchase via the
|
||||||
|
bill line categorized to a COGS/expense account). The earlier decision to leave the BS untouched stands.
|
||||||
|
- Purchase receiving creates a `Purchase`-type `InventoryTransaction` that posts **no** GL — correct.
|
||||||
|
- The **perpetual** consumption-COGS path (O6: `DR COGS / CR Inventory` on JobUsage/Waste) is **opt-in**:
|
||||||
|
it only fires when an item has *both* `CogsAccountId` and `InventoryAccountId`, which are set **only via
|
||||||
|
CSV import** (never by normal item creation). Under this policy those mappings should be left empty, so
|
||||||
|
the path stays dormant. O6's recompute fix remains correct and harmless when unused.
|
||||||
|
- **⚠ Footgun to avoid:** do **not** both expense powder at purchase (bill → COGS account) *and* map an
|
||||||
|
item's `CogsAccountId` + `InventoryAccountId` — that would record the cost twice (once at purchase, once at
|
||||||
|
consumption). Keep item COGS/Inventory account mappings empty under the expense-at-purchase policy.
|
||||||
|
|
||||||
|
#### O9 update (2026-06-20) — default GL accounts feature widened this surface
|
||||||
|
- The original O9 note assumed item `CogsAccountId`/`InventoryAccountId` are "set only via CSV import, never
|
||||||
|
by normal item creation." **That assumption no longer holds.** A new **Default Accounts** feature
|
||||||
|
(Chart of Accounts → "Set Defaults", stored on `CompanyPreferences.Default{Revenue,Cogs,Inventory}AccountId`)
|
||||||
|
pre-fills these fields on **normal** Inventory/Catalog item creation. A company that sets *both* a default
|
||||||
|
COGS and a default Inventory account will have new items post `DR COGS / CR Inventory` on consumption —
|
||||||
|
i.e. it opts the shop into the perpetual path.
|
||||||
|
- **Why it's still safe:** the defaults are **null by default**, so nothing changes until a company
|
||||||
|
deliberately sets them. The footgun (double-counting under expense-at-purchase) is surfaced with an inline
|
||||||
|
warning on the Default Accounts card and in the Settings help article. Posting and balance-recompute both
|
||||||
|
read the item's *stored* accounts, so there is **no recompute drift** — verified during the 2026-06-20 audit.
|
||||||
|
- **Revenue default fallback:** invoice lines now fall back to `DefaultRevenueAccountId` (active only), then
|
||||||
|
to account 4000 (`InvoicesController.Create`). Resolved at invoice-create time and stored on the
|
||||||
|
`InvoiceItem`, so recompute stays consistent.
|
||||||
|
|
||||||
|
### 2026-06-20 audit — dropdown sub-type→type broadening + deposit account picker
|
||||||
|
Reviewed after broadening account dropdowns from sub-type to parent `AccountType` and adding a user-selectable
|
||||||
|
deposit account. **No ledger-drift bugs found.** Notes:
|
||||||
|
- **Deposit account picker ↔ recompute:** consistent. Live posting debits the chosen `DepositAccountId`, and
|
||||||
|
`LedgerService` reproduces the debit by `DepositAccountId == accountId` (lines ~78/724). Picking a non-default
|
||||||
|
deposit account recomputes correctly.
|
||||||
|
- **Bank / "pay-from" / bank-rec pickers — server-side guard added (2026-06-20):** the submitted account is
|
||||||
|
now validated via `AccountGuard.IsValidMoneyAccountAsync` (active, company-owned, AccountType Asset or
|
||||||
|
Liability) before any posting, at bill `RecordPayment` / `Create(payNow)` / `EditPayment`,
|
||||||
|
`BankReconciliation.Create`, and deposit `Record`. Defense in depth against tampered/stale POSTs. Per the
|
||||||
|
"trust the operator" decision this still allows e.g. A/R (an Asset) as a source — it only rejects
|
||||||
|
non-money types (Revenue/Expense/Equity/COGS).
|
||||||
|
- **Latent deposit imbalance — RESOLVED (2026-06-20):** a deposit saved with a null `DepositAccountId` posted
|
||||||
|
`CR 2300` with no offsetting debit → unbalanced. `DepositsController.Record` now blocks recording when the
|
||||||
|
`2300` Customer Deposits account exists but no deposit/bank account resolves (user must pick one). When `2300`
|
||||||
|
doesn't exist (company not using accounting), no GL posts at all, so the deposit is still allowed through.
|
||||||
|
- **Type/sub-type mismatch risk — RESOLVED (2026-06-20):** account `AccountType` is now **derived** from the
|
||||||
|
chosen `AccountSubType` on create/edit via `AccountClassification.TypeForSubType` (single source of truth,
|
||||||
|
also used by the Create pre-select), so the two can never disagree and the sub-type-based sign convention is
|
||||||
|
always consistent with the displayed type. A read-only sweep of the dev DB (109 accounts) found **0** existing
|
||||||
|
mismatches, so no repair tool was needed.
|
||||||
|
|
||||||
|
### 2026-06-20 — GL trial-balance integrity check (audit "#2")
|
||||||
|
Ran an empirical per-company trial-balance net on stored `Account.CurrentBalance`
|
||||||
|
(`SUM(debit-normal) − SUM(credit-normal)`, which must net to 0 for balanced books). **Both tenants are
|
||||||
|
imbalanced**, but the cause is pre-existing data, **not** this session's changes.
|
||||||
|
|
||||||
|
- **Demo Company:** net Dr **$92,653.63** = **$89,500 opening-balance** entry without an offsetting equity
|
||||||
|
line (normal demo-data artifact) + **$3,153.63** from postings.
|
||||||
|
- **SCP Powder Coating** (opening balance $0, clean start): net Dr **$3,079.52**, entirely from postings.
|
||||||
|
|
||||||
|
**Root cause (forensics on SCP):** AR reconciles correctly (~invoices $21,496 − payments $18,314 ≈ stored
|
||||||
|
$3,079), so AR posted on both sides. But **Revenue has $0 GL movement** (the 24 invoices are header-only —
|
||||||
|
**0 line items** — so the per-`InvoiceItem` revenue credit never fires) and the **payment-side bank debit
|
||||||
|
never posted** ($0 bank delta from 22 payments). This is the classic one-sided posting from
|
||||||
|
(a) imported/header-only invoices and (b) postings to unconfigured (null) offset accounts, which
|
||||||
|
`AccountBalanceService` silently skips. Same architectural class as O2/O6/O7/O8.
|
||||||
|
|
||||||
|
**Conclusion:** this session's work (default GL accounts, deposit guard, money-account guards, tenant sweep)
|
||||||
|
**did not introduce the imbalance** — those changes are read-path + validation + defaults and actually
|
||||||
|
*prevent* this bug class going forward (new invoices now fall back to a default revenue account; deposits/
|
||||||
|
payments are guarded against null money accounts). The existing imbalance is legacy/imported data.
|
||||||
|
|
||||||
|
**Remediation options (owner's call — not auto-applied; SCP is live company data):**
|
||||||
|
- `Recalculate Balances` re-derives from source docs but will **not** conjure revenue for header-only
|
||||||
|
invoices (no line items to credit), so it won't fully fix SCP on its own.
|
||||||
|
- Durable fix: the **JournalEntry single-source** refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`) forces every
|
||||||
|
event to post balanced lines.
|
||||||
|
- Historical cleanup: correcting journal entries (or backfilling revenue/bank accounts on the imported docs
|
||||||
|
then recomputing) — a deliberate data-remediation task.
|
||||||
|
- Worth considering: surfacing this trial-balance net as a built-in "GL Health" indicator so drift is visible.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
**All findings O1–O9 + the read-path sweep are resolved** on `dev` (O9 by policy decision — expense at
|
||||||
|
purchase — needing no code change). The optional structural follow-up is the JournalEntry single-source
|
||||||
|
refactor (`docs/ACCOUNTING_LEDGER_REFACTOR.md`), which would prevent the O2/O6/O7/O8 bug class from recurring.
|
||||||
|
The 2026-06-20 trial-balance check confirmed a pre-existing GL imbalance from legacy/imported one-sided
|
||||||
|
postings (not from recent changes) — see above for remediation options.
|
||||||
|
Original audit numbering #1–3/#5/#6/#8 remains unrecoverable (see top). Nothing merged to `master` yet.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Accounting Deploy & Verification Checklist
|
||||||
|
|
||||||
|
> Repeatable steps to run whenever accounting changes ship (and a good periodic health check).
|
||||||
|
> Goal: prove the books are internally consistent with **real data**, not just code review.
|
||||||
|
> Companions: `docs/ACCOUNTING_AUDIT.md` (findings/decisions), `docs/ACCOUNTING_LEDGER_REFACTOR.md` (future).
|
||||||
|
|
||||||
|
The decisive invariant: **a Trial Balance whose total debits == total credits, with no drift on the
|
||||||
|
Balance Reconciliation report, for every company.** If that holds, the ledger is sound by construction.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Pre-deploy (against the production DB, read-only)
|
||||||
|
|
||||||
|
Migrations are applied **manually** at deploy (the app does not auto-migrate). Two are pending from the
|
||||||
|
2026-06 audit; apply in this order:
|
||||||
|
|
||||||
|
1. `RenameDepositsAccountAddPayroll` (O1 — renames 2300 → "Customer Deposits", adds 2400 Payroll)
|
||||||
|
2. `FixGiftCertificateLiabilityAccount` (O5 — relabels mislabeled 2500 → GC Liability, adds 2900)
|
||||||
|
|
||||||
|
Both are non-destructive (no account Id / number / balance is changed; only relabels + additive inserts).
|
||||||
|
Preview exactly what they'll touch (read-only — swap account numbers as needed):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- O1: 2300 rows that will be renamed (only those still named the default), and who gets a new 2400
|
||||||
|
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
|
||||||
|
CASE WHEN Name = 'Payroll Liabilities' THEN 'WILL RENAME -> Customer Deposits' ELSE 'kept as-is' END AS Action
|
||||||
|
FROM Accounts WHERE AccountNumber = '2300' AND IsDeleted = 0 ORDER BY CompanyId;
|
||||||
|
|
||||||
|
-- O5: 2500 rows that will be relabeled (only those still named "Long-Term Loan")
|
||||||
|
SELECT CompanyId, AccountNumber, Name, CurrentBalance,
|
||||||
|
CASE WHEN Name = 'Long-Term Loan' THEN 'WILL RELABEL -> Gift Certificate Liability' ELSE 'kept as-is' END AS Action
|
||||||
|
FROM Accounts WHERE AccountNumber = '2500' AND IsDeleted = 0 ORDER BY CompanyId;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Deploy
|
||||||
|
|
||||||
|
1. Merge `dev` → `master`, trigger the Jenkins production job.
|
||||||
|
2. Apply the two migrations above (in order) to the production DB.
|
||||||
|
|
||||||
|
## 3. Post-deploy verification (per company — all of them)
|
||||||
|
|
||||||
|
Run for **every** company (there are ~7). Most is doable from the app UI under Reports / Finance.
|
||||||
|
|
||||||
|
- [ ] **Trial Balance** — open it. **Total Debits must equal Total Credits.** Any difference is a
|
||||||
|
one-sided posting and must be investigated before trusting other reports.
|
||||||
|
- [ ] **Balance Reconciliation report** (`/Reports/Reconciliation`) — every account's *stored* balance
|
||||||
|
should match the *recomputed* balance (no drift highlighted). Also confirm:
|
||||||
|
- AR control account == sum of customer balances (AR subledger).
|
||||||
|
- AP control account == sum of vendor balances (AP subledger).
|
||||||
|
- [ ] **Recalculate Balances**, then re-open the Trial Balance and Balance Reconciliation. This exercises
|
||||||
|
the recompute paths the audit fixed (`LedgerService`). After a recalc:
|
||||||
|
- Trial Balance still balances.
|
||||||
|
- Reconciliation shows no drift (stored now == recomputed by definition; the point is TB stays balanced
|
||||||
|
and the values look sane).
|
||||||
|
|
||||||
|
## 4. Spot-check the accounts the audit touched
|
||||||
|
|
||||||
|
For each company, glance at these on the Trial Balance / chart of accounts:
|
||||||
|
|
||||||
|
- [ ] **2300 Customer Deposits** — named correctly; balance == outstanding (un-applied) customer deposits.
|
||||||
|
- [ ] **2400 Payroll Liabilities** — exists (likely 0 unless payroll is tracked).
|
||||||
|
- [ ] **2500 Gift Certificate Liability** — named correctly; balance == outstanding GC value (issued − redeemed − voided).
|
||||||
|
- [ ] **2900 Long-Term Loan** — present where the old 2500 was relabeled.
|
||||||
|
- [ ] **4950 Sales Discounts / 4960 Sales Returns** — contra-revenue, show as debit-balance.
|
||||||
|
- [ ] **AR** — for any company that uses gift certificates or has written off an invoice, confirm AR is not
|
||||||
|
overstated (these were O7 / O8). Cross-check AR total against the AR Aging report.
|
||||||
|
|
||||||
|
## 5. If something is off
|
||||||
|
|
||||||
|
- A Trial Balance that doesn't balance → a posting hit only one side. Note the company + amount and check it
|
||||||
|
against the findings in `docs/ACCOUNTING_AUDIT.md` (the resolved O2/O6/O7/O8 patterns) before assuming a new bug.
|
||||||
|
- Drift on the Reconciliation report → run **Recalculate Balances**; if it persists, the recompute is missing a
|
||||||
|
posting type (same class as the audit findings).
|
||||||
|
- Do **not** treat a recalc as a fix for a real imbalance — it makes stored == recomputed, which can *hide* a
|
||||||
|
one-sided posting if only one engine is wrong. The Trial Balance balancing is the real test.
|
||||||
|
|
||||||
|
## 6. Policy reminders (from the audit)
|
||||||
|
|
||||||
|
- **Inventory = expensed at purchase (periodic).** Do **not** map an item's `CogsAccountId` + `InventoryAccountId`
|
||||||
|
(set only via CSV import) while also expensing powder at purchase — that double-counts COGS. Keep those empty.
|
||||||
|
- Sales-tax remittance is capped at the outstanding 2200 balance (O4) — you cannot over-remit.
|
||||||
|
|
||||||
|
## When to repeat
|
||||||
|
|
||||||
|
- After any accounting feature change or import.
|
||||||
|
- As a periodic health check (e.g. monthly), run Section 3 — it's cheap and catches drift early.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Accounting Ledger Refactor — Single Source of Truth
|
||||||
|
|
||||||
|
> Status: **PROPOSED / not started.** This is a captured plan, not in-progress work.
|
||||||
|
> Companion to `docs/ACCOUNTING_AUDIT.md`. Resolves the O2/O6/O7/O8 bug *class* and the open
|
||||||
|
> finding **O9** (inventory capitalization). Do this when there's runway + a staging environment —
|
||||||
|
> it is not an emergency; after audit fixes O1–O8 the current books are correct.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Today every financial event's GL effect is encoded in **three** places:
|
||||||
|
|
||||||
|
1. **`Account.CurrentBalance`** — updated live at each posting site via
|
||||||
|
`AccountBalanceService.DebitAsync/CreditAsync`.
|
||||||
|
2. **`LedgerService`** — re-derives each account's balance by reading *source documents* (Payments,
|
||||||
|
Invoices, Bills, InventoryTransactions, GiftCertificates, CreditMemos, …). Drives
|
||||||
|
`RecalculateAllAsync` and the Balance Reconciliation report.
|
||||||
|
3. **`FinancialReportService`** — *independently* re-derives P&L, Balance Sheet, and Trial Balance from
|
||||||
|
the same documents, with its own per-report logic (largely duplicated across the three reports).
|
||||||
|
|
||||||
|
Adding a transaction type means hand-mirroring its debit and credit into all three engines, in the right
|
||||||
|
direction, in every report method. Missing one spot produced every finding in the audit:
|
||||||
|
|
||||||
|
| Finding | Missed mirror |
|
||||||
|
|---|---|
|
||||||
|
| O2 | 4950 Sales Discounts absent from `LedgerService` → recalc wiped it |
|
||||||
|
| O6 | inventory-consumption COGS absent from both recompute engines |
|
||||||
|
| O7 | GC redemption's AR credit absent → **Trial Balance did not balance** |
|
||||||
|
| O8 | written-off payments filtered inconsistently between the two engines |
|
||||||
|
|
||||||
|
These are one design flaw with four faces: **reports re-interpret documents instead of reading a ledger.**
|
||||||
|
|
||||||
|
## Target
|
||||||
|
|
||||||
|
The journal becomes the only representation of GL effect. The tables already exist
|
||||||
|
(`JournalEntry` + `JournalEntryLine`, with a `Posted` status).
|
||||||
|
|
||||||
|
- **Every** financial event posts a balanced `JournalEntry` (DR == CR enforced once, centrally).
|
||||||
|
- `Account.CurrentBalance`, Trial Balance, Balance Sheet, P&L, and the recompute all derive from
|
||||||
|
**`JournalEntryLine` only** (plus each account's opening balance).
|
||||||
|
- Invoices/Payments/Bills remain *operational* records; they stop carrying implicit GL meaning that every
|
||||||
|
report must re-decode.
|
||||||
|
|
||||||
|
Result: `LedgerService.GetAccountLedgerAsync` (~800 lines of per-document sections) collapses to
|
||||||
|
"sum the JE lines for this account in range." Trial Balance / Balance Sheet become straight per-account JE
|
||||||
|
aggregations. The special cases — `gcRedeemed`, `cmApplied`, refund splits, consumption COGS, discount
|
||||||
|
contra-revenue — all disappear because they are simply journal lines.
|
||||||
|
|
||||||
|
## What changes in the code
|
||||||
|
|
||||||
|
1. **One posting service:** `PostAsync(date, description, lines[])` — validates balanced, assigns the
|
||||||
|
`JE-YYMM-####` number, marks `Posted`, updates `CurrentBalance` from the lines. `AccountBalanceService`
|
||||||
|
becomes a thin wrapper over it.
|
||||||
|
2. **Convert every posting site** to build JE lines instead of ad-hoc Debit/Credit pairs: invoice create,
|
||||||
|
payment, deposit record/apply, refund, credit memo issue/apply/void, GC issue/redeem/void, bill, bill
|
||||||
|
payment, vendor credit, inventory consumption, write-off, sales-tax remittance, year-end close,
|
||||||
|
depreciation, Stripe. Many already call `DebitAsync`; mechanical but touches **every accounting
|
||||||
|
controller**. `InvoicesController.WriteOff` already posts a JE — copy that model everywhere.
|
||||||
|
3. **Edits/voids become reversing entries** (post an opposite JE), never mutate in place.
|
||||||
|
4. **Gut the re-derivation:** `LedgerService` → JE-only; `FinancialReportService` TB/BS/P&L → JE-only.
|
||||||
|
AR/AP **aging** and customer/vendor statements stay document-driven (they answer "which documents are
|
||||||
|
open," not "what is the GL balance"). The AR control account then auto-reconciles to the subledger
|
||||||
|
because both come from the same postings.
|
||||||
|
|
||||||
|
## Migration of historical data
|
||||||
|
|
||||||
|
- **(a) Replay** — re-run every historical document through the new posting logic to emit JEs. Most
|
||||||
|
faithful; must handle edits/voids in chronological order. Complex, higher risk.
|
||||||
|
- **(b) Conversion balances (recommended)** — snapshot each account's current `CurrentBalance` as a single
|
||||||
|
dated "opening balance" JE at a cutover date; only new events post JEs after that. This is how real
|
||||||
|
systems migrate. With ~7 companies it is the pragmatic, low-risk path: pre-cutover per-transaction GL
|
||||||
|
granularity is lost, but every balance is preserved exactly.
|
||||||
|
|
||||||
|
## Phased plan (verifiable, not big-bang)
|
||||||
|
|
||||||
|
- **Phase 0** — build `PostAsync` + balanced-entry invariant + unit tests.
|
||||||
|
- **Phase 1** — route all posting sites through it, creating JEs *alongside* existing behavior (no report
|
||||||
|
changes yet). The **Balance Reconciliation report is the regression harness** — it should show near-zero
|
||||||
|
drift everywhere once every event posts a JE.
|
||||||
|
- **Phase 2** — switch `LedgerService` to JE-only; confirm `RecalculateAllAsync` reproduces balances.
|
||||||
|
- **Phase 3** — switch `FinancialReportService` (TB/BS/P&L) to JE-only; reconcile each report per company
|
||||||
|
against the old numbers using the snapshot harness below.
|
||||||
|
- **Phase 4** — post conversion-balance JEs at the cutover date; delete the dead re-derivation code
|
||||||
|
(~1,000+ lines).
|
||||||
|
- **Phase 5** — **O9 is already decided: expense at purchase (periodic).** Materials are used to deliver a
|
||||||
|
service, not sold, so they are expensed when purchased (bill → COGS/expense account); inventory is **not**
|
||||||
|
capitalized on the Balance Sheet. In the refactor, post material purchases as `DR COGS / CR AP` (expense
|
||||||
|
immediately) and do **not** emit a separate consumption JE. The opt-in perpetual path (item
|
||||||
|
`CogsAccountId` + `InventoryAccountId`) stays unused under this policy — keep those mappings empty to avoid
|
||||||
|
double-counting. No perpetual inventory asset/relief postings.
|
||||||
|
|
||||||
|
## Prerequisites before starting
|
||||||
|
|
||||||
|
- A **report-snapshot/diff harness** to compare each company's P&L / Balance Sheet / Trial Balance before
|
||||||
|
vs after each phase (the safety net for "numbers shifting").
|
||||||
|
- A **staging copy** of production data to run the cutover against first.
|
||||||
|
- A **decision on O9** (the inventory policy) — see `docs/ACCOUNTING_AUDIT.md`.
|
||||||
|
|
||||||
|
## Honest assessment
|
||||||
|
|
||||||
|
- **Effort:** the single biggest change in the accounting area — weeks, not hours, with careful per-company
|
||||||
|
verification.
|
||||||
|
- **Risk:** broad blast radius (every accounting controller); report numbers *will* shift slightly as they
|
||||||
|
become consistent (the point), so it needs per-company sign-off.
|
||||||
|
- **Optional:** after O1–O8 the current system is correct. The refactor buys *prevention* of this bug class,
|
||||||
|
a real audit trail, and ~1,500 fewer lines of parallel logic. Higher ROI the more accounting features you
|
||||||
|
keep adding.
|
||||||
|
- **Safe stopping point:** Phases 0–1 alone (post JEs everywhere, watch the reconciliation report) are
|
||||||
|
low-risk and immediately valuable. You can stop there and continue later.
|
||||||
|
|
||||||
|
## What it fixes
|
||||||
|
|
||||||
|
- Eliminates the O2/O6/O7/O8 bug class (one place to get each event right).
|
||||||
|
- Trial Balance balances by construction.
|
||||||
|
- Every balance is traceable to a journal entry (audit trail).
|
||||||
|
- Resolves **O9** as part of Phase 5.
|
||||||
|
- Turns the Balance Reconciliation report into a true integrity check rather than a drift detector.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
BEGIN TRANSACTION;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE [CompanyPreferences] ADD [DefaultCogsAccountId] int NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE [CompanyPreferences] ADD [DefaultInventoryAccountId] int NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE [CompanyPreferences] ADD [DefaultRevenueAccountId] int NULL;
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644507Z''
|
||||||
|
WHERE [Id] = 1;
|
||||||
|
SELECT @@ROWCOUNT');
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644514Z''
|
||||||
|
WHERE [Id] = 2;
|
||||||
|
SELECT @@ROWCOUNT');
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
EXEC(N'UPDATE [PricingTiers] SET [CreatedAt] = ''2026-06-20T13:49:14.5644515Z''
|
||||||
|
WHERE [Id] = 3;
|
||||||
|
SELECT @@ROWCOUNT');
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX [IX_CompanyPreferences_DefaultCogsAccountId] ON [CompanyPreferences] ([DefaultCogsAccountId]);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX [IX_CompanyPreferences_DefaultInventoryAccountId] ON [CompanyPreferences] ([DefaultInventoryAccountId]);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
CREATE INDEX [IX_CompanyPreferences_DefaultRevenueAccountId] ON [CompanyPreferences] ([DefaultRevenueAccountId]);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultCogsAccountId] FOREIGN KEY ([DefaultCogsAccountId]) REFERENCES [Accounts] ([Id]);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultInventoryAccountId] FOREIGN KEY ([DefaultInventoryAccountId]) REFERENCES [Accounts] ([Id]);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
ALTER TABLE [CompanyPreferences] ADD CONSTRAINT [FK_CompanyPreferences_Accounts_DefaultRevenueAccountId] FOREIGN KEY ([DefaultRevenueAccountId]) REFERENCES [Accounts] ([Id]);
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT * FROM [__EFMigrationsHistory]
|
||||||
|
WHERE [MigrationId] = N'20260620134918_AddCompanyDefaultGlAccounts'
|
||||||
|
)
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO [__EFMigrationsHistory] ([MigrationId], [ProductVersion])
|
||||||
|
VALUES (N'20260620134918_AddCompanyDefaultGlAccounts', N'8.0.11');
|
||||||
|
END;
|
||||||
|
GO
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
GO
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
namespace PowderCoating.Application.Constants;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Central constants for the Columbia Coatings catalog integration — config keys, platform-setting
|
||||||
|
/// keys, and the derived provenance/manufacturer/category values the mapper assigns. Kept in one
|
||||||
|
/// place so the API client, sync mapper, scheduled job, and purge logic all agree on the strings.
|
||||||
|
/// </summary>
|
||||||
|
public static class ColumbiaIntegrationConstants
|
||||||
|
{
|
||||||
|
// ── Configuration keys (appsettings.json / environment) ───────────────
|
||||||
|
/// <summary>API key — secret, lives in config not the platform-settings DB.</summary>
|
||||||
|
public const string ConfigApiKey = "Columbia:ApiKey";
|
||||||
|
public const string ConfigBaseUrl = "Columbia:BaseUrl";
|
||||||
|
|
||||||
|
/// <summary>Configurable API namespace/base path, so an API version bump is a config change.</summary>
|
||||||
|
public const string ConfigApiBasePath = "Columbia:ApiBasePath";
|
||||||
|
|
||||||
|
public const string DefaultBaseUrl = "https://columbiacoatings.com";
|
||||||
|
public const string DefaultApiBasePath = "/wp-json/cca/v1";
|
||||||
|
|
||||||
|
/// <summary>Resource segment appended to the API base path for product endpoints.</summary>
|
||||||
|
public const string ProductsResource = "/products";
|
||||||
|
|
||||||
|
/// <summary>API caps per_page at 100.</summary>
|
||||||
|
public const int MaxPerPage = 100;
|
||||||
|
|
||||||
|
// ── Platform setting keys (SuperAdmin-managed, non-secret) ────────────
|
||||||
|
public const string SettingEnabled = "ColumbiaSyncEnabled";
|
||||||
|
public const string SettingIntervalDays = "ColumbiaSyncIntervalDays";
|
||||||
|
public const string SettingLastSyncedAt = "ColumbiaLastSyncedAt";
|
||||||
|
public const string SettingLastResult = "ColumbiaLastSyncResult";
|
||||||
|
|
||||||
|
public const int DefaultSyncIntervalDays = 7;
|
||||||
|
|
||||||
|
// ── Provenance ────────────────────────────────────────────────────────
|
||||||
|
/// <summary>Stored in <c>PowderCatalogItem.Source</c> — the purge key for right-to-delete.</summary>
|
||||||
|
public const string SourceName = "Columbia Coatings API";
|
||||||
|
|
||||||
|
// ── Derived manufacturers (PowderCatalogItem.VendorName) ──────────────
|
||||||
|
public const string ManufacturerColumbia = "Columbia Coatings";
|
||||||
|
public const string ManufacturerPpg = "PPG";
|
||||||
|
public const string ManufacturerKp = "KP Pigments";
|
||||||
|
|
||||||
|
// ── Derived category (PowderCatalogItem.Category) ─────────────────────
|
||||||
|
public const string CategoryPowderAdditives = "Powder Additives";
|
||||||
|
}
|
||||||
@@ -154,6 +154,49 @@ public class TrialBalanceLine
|
|||||||
public decimal CreditBalance { get; set; }
|
public decimal CreditBalance { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Balance Reconciliation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diagnostic that surfaces drift in the denormalized balances: each account's stored
|
||||||
|
/// <c>CurrentBalance</c> vs its recomputed ledger balance, plus the AR/AP subledger totals
|
||||||
|
/// (sum of Customer/Vendor CurrentBalance) vs their GL control account balances. Read-only.
|
||||||
|
/// </summary>
|
||||||
|
public class BalanceReconciliationDto
|
||||||
|
{
|
||||||
|
public DateTime AsOf { get; set; }
|
||||||
|
public string CompanyName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public List<BalanceReconciliationLine> AccountLines { get; set; } = new();
|
||||||
|
|
||||||
|
public decimal ArControlBalance { get; set; }
|
||||||
|
public decimal ArSubledgerTotal { get; set; }
|
||||||
|
public decimal ArDifference => ArControlBalance - ArSubledgerTotal;
|
||||||
|
|
||||||
|
public decimal ApControlBalance { get; set; }
|
||||||
|
public decimal ApSubledgerTotal { get; set; }
|
||||||
|
public decimal ApDifference => ApControlBalance - ApSubledgerTotal;
|
||||||
|
|
||||||
|
public IEnumerable<BalanceReconciliationLine> DriftedAccounts => AccountLines.Where(l => !l.IsReconciled);
|
||||||
|
public bool AccountsReconciled => AccountLines.All(l => l.IsReconciled);
|
||||||
|
public bool ArReconciled => Math.Abs(ArDifference) < 0.01m;
|
||||||
|
public bool ApReconciled => Math.Abs(ApDifference) < 0.01m;
|
||||||
|
public bool AllReconciled => AccountsReconciled && ArReconciled && ApReconciled;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BalanceReconciliationLine
|
||||||
|
{
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
public string AccountNumber { get; set; } = string.Empty;
|
||||||
|
public string AccountName { get; set; } = string.Empty;
|
||||||
|
public AccountType AccountType { get; set; }
|
||||||
|
/// <summary>The denormalized Account.CurrentBalance (what most UI reads).</summary>
|
||||||
|
public decimal StoredBalance { get; set; }
|
||||||
|
/// <summary>The balance recomputed from source documents (what RecalculateBalances would set).</summary>
|
||||||
|
public decimal LedgerBalance { get; set; }
|
||||||
|
public decimal Difference => StoredBalance - LedgerBalance;
|
||||||
|
public bool IsReconciled => Math.Abs(Difference) < 0.01m;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
// ── Profit & Loss ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public class ProfitAndLossDto
|
public class ProfitAndLossDto
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Columbia;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tolerant converter for Columbia image fields. WordPress/WooCommerce returns an object
|
||||||
|
/// (<c>{id,src,name,alt}</c>) when an image is present, but an empty array (<c>[]</c>) — or
|
||||||
|
/// sometimes <c>false</c>/empty string — when it is not. A single <see cref="ColumbiaImage"/>
|
||||||
|
/// can't bind to those non-object forms, so this converter reads the object when present and
|
||||||
|
/// yields null for anything else (consuming the token so deserialization continues).
|
||||||
|
/// </summary>
|
||||||
|
public class ColumbiaImageJsonConverter : JsonConverter<ColumbiaImage?>
|
||||||
|
{
|
||||||
|
public override ColumbiaImage? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
switch (reader.TokenType)
|
||||||
|
{
|
||||||
|
case JsonTokenType.StartObject:
|
||||||
|
using (var doc = JsonDocument.ParseValue(ref reader))
|
||||||
|
{
|
||||||
|
var el = doc.RootElement;
|
||||||
|
return new ColumbiaImage
|
||||||
|
{
|
||||||
|
Id = el.TryGetProperty("id", out var id) && id.TryGetInt32(out var i) ? i : 0,
|
||||||
|
Src = GetString(el, "src"),
|
||||||
|
Name = GetString(el, "name"),
|
||||||
|
Alt = GetString(el, "alt"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
case JsonTokenType.StartArray:
|
||||||
|
reader.Skip(); // empty/non-empty array form means "no image"
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Primitive (false / "" / null / number): nothing to consume further.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, ColumbiaImage? value, JsonSerializerOptions options)
|
||||||
|
=> throw new NotSupportedException("Columbia image fields are read-only.");
|
||||||
|
|
||||||
|
private static string GetString(JsonElement el, string property) =>
|
||||||
|
el.TryGetProperty(property, out var v) && v.ValueKind == JsonValueKind.String
|
||||||
|
? v.GetString() ?? string.Empty
|
||||||
|
: string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Columbia;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One page of the Columbia Coatings <c>GET /products</c> response: a list of products plus
|
||||||
|
/// pagination metadata. Property names are snake_case in the API and bound via the snake-case
|
||||||
|
/// naming policy configured on the client's <see cref="JsonSerializerOptions"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class ColumbiaProductsResponse
|
||||||
|
{
|
||||||
|
public List<ColumbiaProduct> Items { get; set; } = new();
|
||||||
|
public ColumbiaPagination? Pagination { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Pagination block returned alongside a product page.</summary>
|
||||||
|
public class ColumbiaPagination
|
||||||
|
{
|
||||||
|
public int Page { get; set; }
|
||||||
|
public int PerPage { get; set; }
|
||||||
|
public int Total { get; set; }
|
||||||
|
public int TotalPages { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A single Columbia Coatings product as returned by the API. This mirrors the wire shape, not our
|
||||||
|
/// catalog model — mapping into <c>PowderCatalogItem</c> happens in the sync mapper. Prices arrive
|
||||||
|
/// as strings; cure/spec fields are free text; documents are direct URLs.
|
||||||
|
/// </summary>
|
||||||
|
public class ColumbiaProduct
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>"simple" or "variable". Variable products carry packaging/size variants in
|
||||||
|
/// <see cref="VariationPricing"/> and leave <see cref="TieredPricing"/> as an empty array.</summary>
|
||||||
|
public string ProductType { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Slug { get; set; } = string.Empty;
|
||||||
|
public string Sku { get; set; } = string.Empty;
|
||||||
|
public string Permalink { get; set; } = string.Empty;
|
||||||
|
public string Status { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Columbia-specific values seen include "In Stock"/"instock", "formulated",
|
||||||
|
/// "drop_shipped", "multiple_variations", "outofstock", "onbackorder" — mixed casing.</summary>
|
||||||
|
public string StockStatus { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// ── Pricing (all strings on the wire) ─────────────────────────────────
|
||||||
|
public string Price { get; set; } = string.Empty;
|
||||||
|
public string RegularPrice { get; set; } = string.Empty;
|
||||||
|
public string SalePrice { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Quantity-break pricing. POLYMORPHIC on the wire: an object
|
||||||
|
/// (<c>{type, minimum_quantity, tiers:[...]}</c>) on simple products, but an empty ARRAY
|
||||||
|
/// (<c>[]</c>) on variable products. Captured as a nullable raw <see cref="JsonElement"/> so
|
||||||
|
/// deserialization never throws on the type mismatch and an absent value is null (not an
|
||||||
|
/// invalid <c>Undefined</c> element); the mapper interprets it.
|
||||||
|
/// </summary>
|
||||||
|
public JsonElement? TieredPricing { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Per-variant pricing for variable products (e.g. Bulk vs 1 lb Bags, or gram sizes).
|
||||||
|
/// Each variant has its own SKU and price. Empty for simple products.</summary>
|
||||||
|
public List<ColumbiaVariationPricing>? VariationPricing { get; set; }
|
||||||
|
|
||||||
|
// ── Documents (direct URLs) ───────────────────────────────────────────
|
||||||
|
public string SafetyDataSheet { get; set; } = string.Empty;
|
||||||
|
public string TechnicalDataSheet { get; set; } = string.Empty;
|
||||||
|
public string ProductFlyer { get; set; } = string.Empty;
|
||||||
|
public string ProductBrochure { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// ── Coating spec free-text ────────────────────────────────────────────
|
||||||
|
public string CureSchedule { get; set; } = string.Empty;
|
||||||
|
public string MilThickness { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Resin chemistry (e.g. "Polyester/TGIC", "TGIC", "Epoxy"). NOT finish/gloss.</summary>
|
||||||
|
public string Type { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string ReleaseDate { get; set; } = string.Empty;
|
||||||
|
public string FormulationDate { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Free-text reformulation log, e.g. "Formulation Change: 05/22/26".</summary>
|
||||||
|
public string FormulationDateChanges { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
// ── Content ───────────────────────────────────────────────────────────
|
||||||
|
/// <summary>HTML product description (WordPress markup).</summary>
|
||||||
|
public string Description { get; set; } = string.Empty;
|
||||||
|
public string ShortDescription { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public ColumbiaImage? FeaturedImage { get; set; }
|
||||||
|
|
||||||
|
// ── Taxonomy (arrays of {name}) — used at import to derive manufacturer/category, not stored raw ──
|
||||||
|
public List<ColumbiaNamed> Categories { get; set; } = new();
|
||||||
|
public List<ColumbiaNamed> Tags { get; set; } = new();
|
||||||
|
public List<ColumbiaNamed> PaColorGroup { get; set; } = new();
|
||||||
|
public List<ColumbiaAttribute> Attributes { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A pricing variant of a variable product (own SKU, own price/tiers).</summary>
|
||||||
|
public class ColumbiaVariationPricing
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Sku { get; set; } = string.Empty;
|
||||||
|
public List<ColumbiaAttributeValue> Attributes { get; set; } = new();
|
||||||
|
public string StockStatus { get; set; } = string.Empty;
|
||||||
|
public string Price { get; set; } = string.Empty;
|
||||||
|
public string RegularPrice { get; set; } = string.Empty;
|
||||||
|
public string SalePrice { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Same polymorphic object-or-array shape as the parent; captured raw (nullable).</summary>
|
||||||
|
public JsonElement? TieredPricing { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An image object — featured or gallery.</summary>
|
||||||
|
public class ColumbiaImage
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public string Src { get; set; } = string.Empty;
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Alt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A simple <c>{name}</c> taxonomy entry (category, tag, or color group).</summary>
|
||||||
|
public class ColumbiaNamed
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A product attribute with its option list, e.g. Color Group → [Blue], Packaging → [Bulk, 1 lb Bags].</summary>
|
||||||
|
public class ColumbiaAttribute
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public List<ColumbiaNamed> Options { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A resolved attribute value on a specific variation, e.g. Packaging → "Bulk".</summary>
|
||||||
|
public class ColumbiaAttributeValue
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string Value { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for importing vendor bill headers from CSV. Column names match the native bills export
|
||||||
|
/// (ExportBillsCsv) for round-trip compatibility. The vendor is resolved by name and the AP account
|
||||||
|
/// by number so accounting linkages survive. Line items import separately via BillLineItemImportDto.
|
||||||
|
/// </summary>
|
||||||
|
public class BillImportDto
|
||||||
|
{
|
||||||
|
[Name("BillNumber")]
|
||||||
|
public string? BillNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("VendorInvoiceNumber")]
|
||||||
|
public string? VendorInvoiceNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Vendor company name, matched against Vendor.CompanyName.</summary>
|
||||||
|
[Name("VendorName")]
|
||||||
|
public string? VendorName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>AP account number (Chart of Accounts) this bill posts to.</summary>
|
||||||
|
[Name("APAccountNumber")]
|
||||||
|
public string? APAccountNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("BillDate")]
|
||||||
|
public DateTime BillDate { get; set; }
|
||||||
|
|
||||||
|
[Name("DueDate")]
|
||||||
|
public DateTime? DueDate { get; set; }
|
||||||
|
|
||||||
|
[Name("Status")]
|
||||||
|
public string Status { get; set; } = "Open";
|
||||||
|
|
||||||
|
[Name("Terms")]
|
||||||
|
public string? Terms { get; set; }
|
||||||
|
|
||||||
|
[Name("Memo")]
|
||||||
|
public string? Memo { get; set; }
|
||||||
|
|
||||||
|
[Name("SubTotal")]
|
||||||
|
public decimal SubTotal { get; set; }
|
||||||
|
|
||||||
|
[Name("TaxPercent")]
|
||||||
|
public decimal TaxPercent { get; set; }
|
||||||
|
|
||||||
|
[Name("TaxAmount")]
|
||||||
|
public decimal TaxAmount { get; set; }
|
||||||
|
|
||||||
|
[Name("Total")]
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
|
||||||
|
[Name("AmountPaid")]
|
||||||
|
public decimal AmountPaid { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for importing vendor bill line items from CSV. Column names match the native bill-items export
|
||||||
|
/// (ExportBillLineItemsCsv). Lines are matched to their parent bill by BillNumber; the expense/asset
|
||||||
|
/// account is resolved (optional) from AccountNumber so each line's GL attribution round-trips.
|
||||||
|
/// </summary>
|
||||||
|
public class BillLineItemImportDto
|
||||||
|
{
|
||||||
|
[Name("BillNumber")]
|
||||||
|
public string? BillNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Expense/asset account number this line is categorized under. Optional.</summary>
|
||||||
|
[Name("AccountNumber")]
|
||||||
|
public string? AccountNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optional job-costing link, matched against Job.JobNumber.</summary>
|
||||||
|
[Name("JobNumber")]
|
||||||
|
public string? JobNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("Description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[Name("Quantity")]
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
[Name("UnitPrice")]
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
|
||||||
|
[Name("Amount")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[Name("DisplayOrder")]
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for importing customer deposits from CSV. Column names match the native deposits export
|
||||||
|
/// (ExportDepositsCsv). The customer is resolved by name, the bank account by number
|
||||||
|
/// (DepositAccountNumber), and the optional applied invoice by number so the deposit's linkages
|
||||||
|
/// survive an export/import round-trip.
|
||||||
|
/// </summary>
|
||||||
|
public class DepositImportDto
|
||||||
|
{
|
||||||
|
[Name("ReceiptNumber")]
|
||||||
|
public string? ReceiptNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Customer name (company name, or contact full name), matched against the customer record.</summary>
|
||||||
|
[Name("CustomerName")]
|
||||||
|
public string? CustomerName { get; set; }
|
||||||
|
|
||||||
|
[Name("Amount")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Valid values: Cash, Check, CreditDebitCard, BankTransferACH, DigitalPayment</summary>
|
||||||
|
[Name("PaymentMethod")]
|
||||||
|
public string PaymentMethod { get; set; } = "Cash";
|
||||||
|
|
||||||
|
[Name("ReceivedDate")]
|
||||||
|
public DateTime ReceivedDate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Bank/cash account number (Chart of Accounts) the deposit landed in. Optional.</summary>
|
||||||
|
[Name("DepositAccountNumber")]
|
||||||
|
public string? DepositAccountNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Invoice number this deposit has been applied to, if any. Optional.</summary>
|
||||||
|
[Name("AppliedToInvoiceNumber")]
|
||||||
|
public string? AppliedToInvoiceNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("AppliedDate")]
|
||||||
|
public DateTime? AppliedDate { get; set; }
|
||||||
|
|
||||||
|
[Name("Reference")]
|
||||||
|
public string? Reference { get; set; }
|
||||||
|
|
||||||
|
[Name("Notes")]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for importing invoice line items from CSV. Column names match the native
|
||||||
|
/// invoice-items export (ExportInvoiceItemsCsv) for round-trip compatibility.
|
||||||
|
/// Line items are matched to their parent invoice by <c>InvoiceNumber</c>; the revenue
|
||||||
|
/// account is resolved from <c>RevenueAccountNumber</c> against Account.AccountNumber so the
|
||||||
|
/// invoice's revenue attribution survives an export/import round-trip.
|
||||||
|
/// </summary>
|
||||||
|
public class InvoiceItemImportDto
|
||||||
|
{
|
||||||
|
[Name("InvoiceNumber")]
|
||||||
|
public string? InvoiceNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("Description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[Name("Quantity")]
|
||||||
|
public decimal Quantity { get; set; }
|
||||||
|
|
||||||
|
[Name("UnitPrice")]
|
||||||
|
public decimal UnitPrice { get; set; }
|
||||||
|
|
||||||
|
[Name("TotalPrice")]
|
||||||
|
public decimal TotalPrice { get; set; }
|
||||||
|
|
||||||
|
[Name("ColorName")]
|
||||||
|
public string? ColorName { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Account number (Chart of Accounts) of the revenue account this line posts to. Optional —
|
||||||
|
/// a blank value means the line falls back to the company's default revenue account.
|
||||||
|
/// </summary>
|
||||||
|
[Name("RevenueAccountNumber")]
|
||||||
|
public string? RevenueAccountNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("DisplayOrder")]
|
||||||
|
public int DisplayOrder { get; set; }
|
||||||
|
|
||||||
|
[Name("Notes")]
|
||||||
|
public string? Notes { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for importing journal entry headers from CSV. Column names match the native journal-entries
|
||||||
|
/// export (ExportJournalEntriesCsv). The debit/credit lines import separately via
|
||||||
|
/// JournalEntryLineImportDto and must balance per entry.
|
||||||
|
/// </summary>
|
||||||
|
public class JournalEntryImportDto
|
||||||
|
{
|
||||||
|
[Name("EntryNumber")]
|
||||||
|
public string? EntryNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("EntryDate")]
|
||||||
|
public DateTime EntryDate { get; set; }
|
||||||
|
|
||||||
|
[Name("Reference")]
|
||||||
|
public string? Reference { get; set; }
|
||||||
|
|
||||||
|
[Name("Description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Valid values: Draft, Posted, Reversed</summary>
|
||||||
|
[Name("Status")]
|
||||||
|
public string Status { get; set; } = "Draft";
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using CsvHelper.Configuration.Attributes;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.DTOs.Import;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for importing journal entry lines from CSV. Column names match the native journal-entry-lines
|
||||||
|
/// export (ExportJournalEntryLinesCsv). Lines are matched to their parent entry by EntryNumber and the
|
||||||
|
/// account is resolved from AccountNumber (required — a JE line is meaningless without its account).
|
||||||
|
/// Either DebitAmount or CreditAmount is non-zero per line, not both.
|
||||||
|
/// </summary>
|
||||||
|
public class JournalEntryLineImportDto
|
||||||
|
{
|
||||||
|
[Name("EntryNumber")]
|
||||||
|
public string? EntryNumber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Account number (Chart of Accounts) this line debits or credits. Required.</summary>
|
||||||
|
[Name("AccountNumber")]
|
||||||
|
public string? AccountNumber { get; set; }
|
||||||
|
|
||||||
|
[Name("DebitAmount")]
|
||||||
|
public decimal DebitAmount { get; set; }
|
||||||
|
|
||||||
|
[Name("CreditAmount")]
|
||||||
|
public decimal CreditAmount { get; set; }
|
||||||
|
|
||||||
|
[Name("Description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[Name("LineOrder")]
|
||||||
|
public int LineOrder { get; set; }
|
||||||
|
}
|
||||||
@@ -24,6 +24,14 @@ public class PaymentImportDto
|
|||||||
[Name("PaymentMethod")]
|
[Name("PaymentMethod")]
|
||||||
public string PaymentMethod { get; set; } = "Cash";
|
public string PaymentMethod { get; set; } = "Cash";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Account number (Chart of Accounts) of the bank/cash account the payment was deposited into.
|
||||||
|
/// Resolved back to <c>DepositAccountId</c> on import so the balance recalc can post it to the
|
||||||
|
/// right bank account. Optional — a blank value means no deposit account was recorded.
|
||||||
|
/// </summary>
|
||||||
|
[Name("DepositAccountNumber")]
|
||||||
|
public string? DepositAccountNumber { get; set; }
|
||||||
|
|
||||||
[Name("Reference")]
|
[Name("Reference")]
|
||||||
public string? Reference { get; set; }
|
public string? Reference { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ public class InventoryItemDto
|
|||||||
public decimal AverageCost { get; set; }
|
public decimal AverageCost { get; set; }
|
||||||
public decimal LastPurchasePrice { get; set; }
|
public decimal LastPurchasePrice { get; set; }
|
||||||
public DateTime? LastPurchaseDate { get; set; }
|
public DateTime? LastPurchaseDate { get; set; }
|
||||||
|
public decimal? CatalogReferencePrice { get; set; }
|
||||||
|
public DateTime? CatalogPriceUpdatedAt { get; set; }
|
||||||
public int? PrimaryVendorId { get; set; }
|
public int? PrimaryVendorId { get; set; }
|
||||||
public string? PrimaryVendorName { get; set; }
|
public string? PrimaryVendorName { get; set; }
|
||||||
public string? VendorPartNumber { get; set; }
|
public string? VendorPartNumber { get; set; }
|
||||||
@@ -223,6 +225,12 @@ public class CreateInventoryItemDto
|
|||||||
[Display(Name = "Incoming / On Order")]
|
[Display(Name = "Incoming / On Order")]
|
||||||
public bool IsIncoming { get; set; }
|
public bool IsIncoming { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Existing inventory record the user explicitly chose to bypass when creating a separate
|
||||||
|
/// powder lot or location. SKU duplicates can never be bypassed.
|
||||||
|
/// </summary>
|
||||||
|
public int? DuplicateOverrideInventoryItemId { get; set; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public class UpdateInventoryItemDto : CreateInventoryItemDto
|
public class UpdateInventoryItemDto : CreateInventoryItemDto
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Orchestrates a full Columbia Coatings catalog sync: pull every product, map and upsert it, then
|
||||||
|
/// (only on a complete pull) reconcile discontinuations. Used by both the scheduled background job
|
||||||
|
/// and the manual "Sync now" admin action.
|
||||||
|
/// </summary>
|
||||||
|
public interface IColumbiaCatalogSyncService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Runs one full sync. Assumes the caller has already decided it should run (enabled / due).
|
||||||
|
/// Returns a result describing the outcome; never throws for an expected failure (not
|
||||||
|
/// configured, partial pull, HTTP error) — those are reported on the result instead.
|
||||||
|
/// </summary>
|
||||||
|
Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Outcome of a Columbia catalog sync run.</summary>
|
||||||
|
public class ColumbiaSyncResult
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
|
||||||
|
public int TotalFetched { get; set; }
|
||||||
|
public int Inserted { get; set; }
|
||||||
|
public int Updated { get; set; }
|
||||||
|
public int Unchanged { get; set; }
|
||||||
|
public int Skipped { get; set; }
|
||||||
|
public int Discontinued { get; set; }
|
||||||
|
public int Reactivated { get; set; }
|
||||||
|
|
||||||
|
public DateTime StartedAt { get; set; }
|
||||||
|
public TimeSpan Duration { get; set; }
|
||||||
|
|
||||||
|
/// <summary>One-line summary suitable for storing in the last-result platform setting / UI.</summary>
|
||||||
|
public string Summary =>
|
||||||
|
Success
|
||||||
|
? $"{TotalFetched} fetched: {Inserted} new, {Updated} updated, {Unchanged} unchanged, " +
|
||||||
|
$"{Discontinued} discontinued, {Reactivated} reactivated ({Duration.TotalSeconds:F0}s)"
|
||||||
|
: $"Failed: {ErrorMessage}";
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using PowderCoating.Application.DTOs.Columbia;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Typed client for the Columbia Coatings product catalog API (<c>/wp-json/cca/v1</c>).
|
||||||
|
/// Read-only: lists products via the paged <c>GET /products</c> endpoint.
|
||||||
|
/// <para>
|
||||||
|
/// We deliberately page <c>/products</c> rather than using the bulk <c>export.json</c> download:
|
||||||
|
/// the export returns a temporary <c>download_url</c> to a static file under <c>/wp-content/uploads</c>,
|
||||||
|
/// which sits behind Cloudflare bot protection and 403s for non-browser clients. The
|
||||||
|
/// <c>/wp-json</c> API routes are allowlisted via the API key, so paging is the only path that
|
||||||
|
/// works reliably from a server.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public interface IColumbiaCoatingsApiClient
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// True when an API key is configured (<c>Columbia:ApiKey</c>). When false, callers should
|
||||||
|
/// skip the sync entirely rather than issue unauthenticated requests.
|
||||||
|
/// </summary>
|
||||||
|
bool IsConfigured { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a single page of products. <paramref name="perPage"/> is capped at 100 by the API.
|
||||||
|
/// </summary>
|
||||||
|
Task<ColumbiaProductsResponse> GetProductsPageAsync(int page, int perPage, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pages through the entire catalog and returns every product. Honors rate limiting
|
||||||
|
/// (429 / Retry-After). THROWS if any page fails after retries — callers must treat an
|
||||||
|
/// exception as "incomplete pull" and NOT run discontinuation logic against a partial set.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a single product by exact SKU (<c>GET /products?sku=...</c>), or null if not found.
|
||||||
|
/// For ad-hoc refresh of one record without pulling the whole catalog.
|
||||||
|
/// </summary>
|
||||||
|
Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches a single product by WooCommerce product ID (<c>GET /products/{id}</c>), or null if
|
||||||
|
/// not found. Useful when we already store the catalog product's ID and want to refresh it.
|
||||||
|
/// </summary>
|
||||||
|
Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -179,10 +179,36 @@ public interface ICsvImportService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then
|
/// Import invoice headers from a CSV stream. Customers are resolved by CustomerEmail then
|
||||||
/// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices
|
/// CustomerName. Duplicate detection uses InvoiceNumber as the unique key. Existing invoices
|
||||||
/// are updated; new ones are created. Line items are not part of the CSV format.
|
/// are updated; new ones are created. Line items are imported separately via
|
||||||
|
/// <see cref="ImportInvoiceItemsAsync"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId);
|
Task<CsvImportResultDto> ImportInvoicesAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Import invoice line items from a CSV stream. Each line is matched to its parent invoice by
|
||||||
|
/// InvoiceNumber and its revenue account resolved (optional) from RevenueAccountNumber. Idempotent
|
||||||
|
/// by description + total + display order. Run after invoices have been imported.
|
||||||
|
/// </summary>
|
||||||
|
Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
|
/// <summary>Import vendor bill headers. Vendor by name, AP account by number. Dedup by BillNumber.</summary>
|
||||||
|
Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
|
/// <summary>Import vendor bill line items. Matched to bills by BillNumber; account/job by number.</summary>
|
||||||
|
Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
|
/// <summary>Import customer deposits. Customer by name, bank account by number, applied invoice by number.</summary>
|
||||||
|
Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
|
/// <summary>Import journal entry headers. Dedup by EntryNumber. Lines import separately.</summary>
|
||||||
|
Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
|
/// <summary>Import journal entry lines. Matched to entries by EntryNumber; account by number (required).</summary>
|
||||||
|
Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId);
|
||||||
|
|
||||||
|
/// <summary>Generate a CSV template file for invoice line-item imports.</summary>
|
||||||
|
byte[] GenerateInvoiceItemTemplate();
|
||||||
|
|
||||||
/// <summary>Generate a CSV template file for payment imports.</summary>
|
/// <summary>Generate a CSV template file for payment imports.</summary>
|
||||||
byte[] GeneratePaymentTemplate();
|
byte[] GeneratePaymentTemplate();
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ public interface IFinancialReportService
|
|||||||
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
/// <summary>Returns a Trial Balance using current account balances as of the given date.</summary>
|
||||||
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
Task<TrialBalanceDto> GetTrialBalanceAsync(int companyId, DateTime asOf);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a balance reconciliation: each account's stored CurrentBalance vs its recomputed ledger
|
||||||
|
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only drift diagnostic.
|
||||||
|
/// </summary>
|
||||||
|
Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId);
|
||||||
|
|
||||||
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
/// <summary>Looks up the accounting method configured for the given company. Returns Accrual if not found.</summary>
|
||||||
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
Task<AccountingMethod> GetCompanyAccountingMethodAsync(int companyId);
|
||||||
|
|
||||||
|
|||||||
@@ -59,9 +59,17 @@ public interface IInventoryAiLookupService
|
|||||||
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
|
Task<InventoryAiLookupResult> ScanLabelAsync(string base64Image, string mediaType);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches a Technical Data Sheet URL and extracts cure temperature and cure time.
|
/// Fetches a Technical Data Sheet URL and extracts cure temperature, cure time, and specific
|
||||||
/// Called when the main lookup found a TDS URL but cure specs are still missing.
|
/// gravity. Called when the main lookup found a TDS URL but specs are still missing.
|
||||||
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
|
/// Returns Success=false silently (no UI error) when the TDS is a PDF or unreachable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
|
Task<InventoryAiLookupResult> FetchTdsCureSpecsAsync(string tdsUrl, string? colorName);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lazily fills a powder catalog item's specific gravity (and any missing cure specs) from its
|
||||||
|
/// TDS the first time it's needed, then derives theoretical coverage. No-op when specific
|
||||||
|
/// gravity is already known or no TDS URL is present. Persists the enrichment to the catalog so
|
||||||
|
/// it's done once and benefits every future use. Returns true if anything was filled.
|
||||||
|
/// </summary>
|
||||||
|
Task<bool> EnsureCatalogTdsSpecsAsync(PowderCoating.Core.Entities.PowderCatalogItem catalog);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared upsert for the platform powder catalog: matches incoming records to existing rows by
|
||||||
|
/// (VendorName, SKU), inserts new ones, and updates changed ones in place. Used by BOTH the manual
|
||||||
|
/// JSON file import and the Columbia API sync so there is a single upsert path, only the mapping
|
||||||
|
/// differs. Does NOT handle discontinuation — that is a sync-specific concern.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPowderCatalogUpsertService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies <paramref name="incoming"/> mapped catalog items. Only fields sourced from the feed
|
||||||
|
/// are copied on update; enrichment fields (specific gravity, coverage, transfer efficiency,
|
||||||
|
/// finish) are preserved so they are not wiped by a feed that never carries them. Changed and
|
||||||
|
/// inserted rows get <paramref name="runTimestamp"/> stamped on LastSyncedAt/UpdatedAt.
|
||||||
|
/// </summary>
|
||||||
|
Task<PowderCatalogUpsertResult> UpsertAsync(
|
||||||
|
IReadOnlyList<PowderCatalogItem> incoming,
|
||||||
|
DateTime runTimestamp,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Counts from an upsert run.</summary>
|
||||||
|
public class PowderCatalogUpsertResult
|
||||||
|
{
|
||||||
|
public int Inserted { get; set; }
|
||||||
|
public int Updated { get; set; }
|
||||||
|
public int Unchanged { get; set; }
|
||||||
|
public int Skipped { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
namespace PowderCoating.Application.Services;
|
||||||
|
|
||||||
|
public enum InventoryDuplicateMatchType
|
||||||
|
{
|
||||||
|
Sku,
|
||||||
|
ManufacturerPartNumber,
|
||||||
|
ManufacturerColor
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed record InventoryDuplicateMatch(
|
||||||
|
InventoryItem Item,
|
||||||
|
InventoryDuplicateMatchType MatchType);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared inventory duplicate rules used by manual creation and powder-label scanning.
|
||||||
|
/// Callers are responsible for supplying inventory already restricted to the current tenant.
|
||||||
|
/// </summary>
|
||||||
|
public static class InventoryDuplicateMatcher
|
||||||
|
{
|
||||||
|
public static InventoryDuplicateMatch? Find(
|
||||||
|
IEnumerable<InventoryItem> inventoryItems,
|
||||||
|
int companyId,
|
||||||
|
string? sku,
|
||||||
|
string? manufacturer,
|
||||||
|
string? manufacturerPartNumber,
|
||||||
|
string? colorName,
|
||||||
|
bool isCoating,
|
||||||
|
int? excludeId = null)
|
||||||
|
{
|
||||||
|
var candidates = inventoryItems
|
||||||
|
.Where(i => i.CompanyId == companyId && i.Id != excludeId)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var normalizedSku = Normalize(sku);
|
||||||
|
if (normalizedSku.Length > 0)
|
||||||
|
{
|
||||||
|
var skuMatch = candidates.FirstOrDefault(i => Normalize(i.SKU) == normalizedSku);
|
||||||
|
if (skuMatch != null)
|
||||||
|
return new InventoryDuplicateMatch(skuMatch, InventoryDuplicateMatchType.Sku);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isCoating)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var coatingCandidates = candidates
|
||||||
|
.Where(i => i.InventoryCategory?.IsCoating == true)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var normalizedManufacturer = Normalize(manufacturer);
|
||||||
|
var normalizedPartNumber = Normalize(manufacturerPartNumber);
|
||||||
|
if (normalizedPartNumber.Length > 0)
|
||||||
|
{
|
||||||
|
var partNumberMatch = coatingCandidates.FirstOrDefault(i =>
|
||||||
|
Normalize(i.ManufacturerPartNumber) == normalizedPartNumber &&
|
||||||
|
(normalizedManufacturer.Length == 0 ||
|
||||||
|
Normalize(i.Manufacturer) == normalizedManufacturer));
|
||||||
|
|
||||||
|
if (partNumberMatch != null)
|
||||||
|
return new InventoryDuplicateMatch(
|
||||||
|
partNumberMatch,
|
||||||
|
InventoryDuplicateMatchType.ManufacturerPartNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedColorName = Normalize(colorName);
|
||||||
|
if (normalizedManufacturer.Length == 0 || normalizedColorName.Length == 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var manufacturerColorMatch = coatingCandidates.FirstOrDefault(i =>
|
||||||
|
Normalize(i.Manufacturer) == normalizedManufacturer &&
|
||||||
|
Normalize(i.ColorName ?? i.Name) == normalizedColorName);
|
||||||
|
|
||||||
|
return manufacturerColorMatch == null
|
||||||
|
? null
|
||||||
|
: new InventoryDuplicateMatch(
|
||||||
|
manufacturerColorMatch,
|
||||||
|
InventoryDuplicateMatchType.ManufacturerColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
return string.Empty;
|
||||||
|
|
||||||
|
return string.Join(
|
||||||
|
' ',
|
||||||
|
value.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
.ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -149,9 +149,12 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
var inventoryItem = await _unitOfWork.InventoryItems.GetByIdAsync(coat.InventoryItemId.Value);
|
||||||
if (inventoryItem != null && inventoryItem.UnitCost > 0)
|
// Prefer the current catalog price (replacement cost) so quotes reflect the latest
|
||||||
|
// price; fall back to the item's own cost when it isn't catalog-linked.
|
||||||
|
var effectiveCostPerLb = inventoryItem?.CatalogReferencePrice ?? inventoryItem?.UnitCost ?? 0m;
|
||||||
|
if (inventoryItem != null && effectiveCostPerLb > 0)
|
||||||
{
|
{
|
||||||
costPerLb = inventoryItem.UnitCost;
|
costPerLb = effectiveCostPerLb;
|
||||||
isIncomingPowder = inventoryItem.IsIncoming;
|
isIncomingPowder = inventoryItem.IsIncoming;
|
||||||
var coverage = coat.CoverageSqFtPerLb;
|
var coverage = coat.CoverageSqFtPerLb;
|
||||||
var transferEfficiency = coat.TransferEfficiency;
|
var transferEfficiency = coat.TransferEfficiency;
|
||||||
@@ -160,8 +163,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
|
var actualPoundsPerSqFt = poundsPerSqFt / (transferEfficiency / 100m);
|
||||||
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
|
powderCostPerSqFt = actualPoundsPerSqFt * costPerLb;
|
||||||
|
|
||||||
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), UnitCost={UnitCost}/lb, Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
_logger.LogInformation("Coat {CoatName}: Using inventory item: {InventoryItem} (IsIncoming={IsIncoming}), CostPerLb={CostPerLb}/lb (catalog ref={CatalogRef}), Coverage={Coverage}sqft/lb, Efficiency={Efficiency}%, Calculated={CalcCost}/sqft",
|
||||||
coat.CoatName, inventoryItem.Name, isIncomingPowder, inventoryItem.UnitCost, coverage, transferEfficiency, powderCostPerSqFt);
|
coat.CoatName, inventoryItem.Name, isIncomingPowder, costPerLb, inventoryItem.CatalogReferencePrice, coverage, transferEfficiency, powderCostPerSqFt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -691,7 +694,8 @@ public class PricingCalculationService : IPricingCalculationService
|
|||||||
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
var invItem = await _unitOfWork.InventoryItems.GetByIdAsync(c.InventoryItemId.Value);
|
||||||
if (invItem?.IsIncoming == true)
|
if (invItem?.IsIncoming == true)
|
||||||
{
|
{
|
||||||
customPowderOrderAmount += c.PowderToOrder.Value * invItem.UnitCost;
|
// Bill the powder-to-order at the current catalog price when linked.
|
||||||
|
customPowderOrderAmount += c.PowderToOrder.Value * (invItem.CatalogReferencePrice ?? invItem.UnitCost);
|
||||||
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
var colorName = !string.IsNullOrWhiteSpace(c.ColorName) ? c.ColorName : invItem.Name;
|
||||||
if (!string.IsNullOrWhiteSpace(colorName))
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
customPowderOrderColors.Add(colorName);
|
customPowderOrderColors.Add(colorName);
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace PowderCoating.Core.Accounting;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth for splitting a customer refund into its revenue (returns) portion and
|
||||||
|
/// its sales-tax portion, under the "reverse the sale" model. A refund of a paid invoice reverses
|
||||||
|
/// the original sale: the revenue portion is debited to Sales Returns (contra-revenue) and the tax
|
||||||
|
/// portion is debited to Sales Tax Payable (reducing the liability), with cash credited out.
|
||||||
|
///
|
||||||
|
/// The split is proportional to the parent invoice's tax ratio so a partial refund reverses the
|
||||||
|
/// right amount of tax. Centralised here so the posting (<c>InvoicesController</c>) and the two
|
||||||
|
/// reporting recomputes (<c>LedgerService</c>, <c>FinancialReportService</c>) always agree — if they
|
||||||
|
/// computed it independently the trial balance could drift.
|
||||||
|
/// </summary>
|
||||||
|
public static class RefundAllocation
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Splits <paramref name="refundAmount"/> (tax-inclusive) into (returnsPortion, taxPortion) using
|
||||||
|
/// the parent invoice's tax ratio. When the invoice has no total or no tax, the whole refund is
|
||||||
|
/// the returns portion and the tax portion is zero.
|
||||||
|
/// </summary>
|
||||||
|
public static (decimal ReturnsPortion, decimal TaxPortion) Split(
|
||||||
|
decimal refundAmount, decimal invoiceTaxAmount, decimal invoiceTotal)
|
||||||
|
{
|
||||||
|
var taxPortion = invoiceTotal > 0m && invoiceTaxAmount > 0m
|
||||||
|
? Math.Round(refundAmount * invoiceTaxAmount / invoiceTotal, 2, MidpointRounding.AwayFromZero)
|
||||||
|
: 0m;
|
||||||
|
return (refundAmount - taxPortion, taxPortion);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,18 @@ public class CompanyPreferences : BaseEntity
|
|||||||
public string InvoiceNumberPrefix { get; set; } = "INV";
|
public string InvoiceNumberPrefix { get; set; } = "INV";
|
||||||
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
|
public bool UseMetricSystem { get; set; } = false; // False = Imperial (ft, lb), True = Metric (m, kg)
|
||||||
|
|
||||||
|
// Default GL Accounts — used as the fallback when an item leaves its account field blank.
|
||||||
|
// Null means "no default": revenue falls back to account 4000, and inventory-consumption
|
||||||
|
// COGS simply isn't posted (consistent with expensing materials at purchase). A company
|
||||||
|
// only opts into perpetual-inventory COGS posting by setting both the COGS and Inventory
|
||||||
|
// defaults (or the per-item accounts). FKs are nullable with no cascade — accounts soft-delete.
|
||||||
|
/// <summary>Default Revenue account for invoice lines that don't specify one (fallback before account 4000).</summary>
|
||||||
|
public int? DefaultRevenueAccountId { get; set; }
|
||||||
|
/// <summary>Default COGS account pre-filled on new inventory/catalog items. Drives inventory-consumption COGS posting when paired with an inventory account.</summary>
|
||||||
|
public int? DefaultCogsAccountId { get; set; }
|
||||||
|
/// <summary>Default Inventory asset account pre-filled on new inventory items. Drives inventory-consumption COGS posting when paired with a COGS account.</summary>
|
||||||
|
public int? DefaultInventoryAccountId { get; set; }
|
||||||
|
|
||||||
// Job / Workflow Defaults
|
// Job / Workflow Defaults
|
||||||
public string DefaultJobPriority { get; set; } = "Normal";
|
public string DefaultJobPriority { get; set; } = "Normal";
|
||||||
public bool RequireCustomerPO { get; set; } = false;
|
public bool RequireCustomerPO { get; set; } = false;
|
||||||
|
|||||||
@@ -31,6 +31,27 @@ public class InventoryItem : BaseEntity
|
|||||||
public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry)
|
public string? SdsUrl { get; set; } // Safety Data Sheet URL (from powder catalog or manual entry)
|
||||||
public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry)
|
public string? TdsUrl { get; set; } // Technical Data Sheet URL (from powder catalog or manual entry)
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optional link to the platform powder catalog record this item was created from.
|
||||||
|
/// Populated when an item is added via the catalog lookup, or back-filled by Manufacturer+SKU.
|
||||||
|
/// Lets the inventory detail screen surface manufacturer-level status (e.g. "discontinued by
|
||||||
|
/// manufacturer — cannot reorder") and future price/reformulation change flags. Nulled — not
|
||||||
|
/// cascaded — if the source catalog data is purged (the shop's own stock record must survive).
|
||||||
|
/// </summary>
|
||||||
|
public int? PowderCatalogItemId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Latest list price from the linked powder catalog, refreshed by the catalog sync. This is the
|
||||||
|
/// QUOTING price (current replacement cost) and is kept deliberately SEPARATE from
|
||||||
|
/// <see cref="UnitCost"/>/<see cref="AverageCost"/> (the actual paid cost basis that drives
|
||||||
|
/// inventory valuation and COGS). Quoting prefers this when present so quotes reflect the
|
||||||
|
/// current price; accounting never reads it. Null for manual/non-catalog powders.
|
||||||
|
/// </summary>
|
||||||
|
public decimal? CatalogReferencePrice { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Timestamp (UTC) when <see cref="CatalogReferencePrice"/> was last refreshed by the sync.</summary>
|
||||||
|
public DateTime? CatalogPriceUpdatedAt { get; set; }
|
||||||
|
|
||||||
// Sample Panel Tracking (coating category items only)
|
// Sample Panel Tracking (coating category items only)
|
||||||
public bool HasSamplePanel { get; set; } = false;
|
public bool HasSamplePanel { get; set; } = false;
|
||||||
|
|
||||||
|
|||||||
@@ -40,9 +40,30 @@ public class PowderCatalogItem
|
|||||||
/// <summary>Cure hold time at cure temperature, in minutes.</summary>
|
/// <summary>Cure hold time at cure temperature, in minutes.</summary>
|
||||||
public int? CureTimeMinutes { get; set; }
|
public int? CureTimeMinutes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw cure schedule text exactly as supplied by the vendor — e.g. "10 minutes @ 400°F".
|
||||||
|
/// Preserved verbatim because vendor formats vary wildly and some carry application notes
|
||||||
|
/// that don't reduce to a single temp/time pair (partial cures, clear-coat steps).
|
||||||
|
/// </summary>
|
||||||
|
public string? CureScheduleText { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All parsed cure curves as JSON — e.g. [{"tempF":400,"minutes":10},{"tempF":350,"minutes":20}].
|
||||||
|
/// Many powders list alternate lower-temperature curves; these matter for heat-sensitive
|
||||||
|
/// substrates that cannot take the standard 400°F cure, so we keep every curve, not just the
|
||||||
|
/// primary one in <see cref="CureTemperatureF"/>/<see cref="CureTimeMinutes"/>.
|
||||||
|
/// </summary>
|
||||||
|
public string? CureCurvesJson { get; set; }
|
||||||
|
|
||||||
/// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary>
|
/// <summary>Finish type — e.g. Gloss, Matte, Satin, Metallic, Texture.</summary>
|
||||||
public string? Finish { get; set; }
|
public string? Finish { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Resin chemistry — e.g. "Polyester", "TGIC", "Epoxy", "Hybrid". Distinct from <see cref="Finish"/>.</summary>
|
||||||
|
public string? ChemistryType { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Recommended film build (mil thickness) as free text from the vendor — e.g. "2.0-3.0 Mils".</summary>
|
||||||
|
public string? MilThickness { get; set; }
|
||||||
|
|
||||||
/// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary>
|
/// <summary>Comma-separated color family tags — e.g. "Blue,Purple".</summary>
|
||||||
public string? ColorFamilies { get; set; }
|
public string? ColorFamilies { get; set; }
|
||||||
|
|
||||||
@@ -60,6 +81,29 @@ public class PowderCatalogItem
|
|||||||
|
|
||||||
// ── Catalog management ────────────────────────────────────────────────
|
// ── Catalog management ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Our internal product category — e.g. "Powder Additives" for pigments/additives that are
|
||||||
|
/// sold by weight in grams and mixed into clear rather than sprayed as a standalone powder.
|
||||||
|
/// Null/empty for standard powders. Derived at import from the vendor's taxonomy, NOT stored
|
||||||
|
/// from their raw category list.
|
||||||
|
/// </summary>
|
||||||
|
public string? Category { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provenance of this record — e.g. "Columbia Coatings API". Kept SEPARATE from
|
||||||
|
/// <see cref="VendorName"/> (which holds the derived manufacturer) so we can honor a
|
||||||
|
/// distributor's right-to-delete by purging every record that came from their feed,
|
||||||
|
/// regardless of which manufacturer made the product.
|
||||||
|
/// </summary>
|
||||||
|
public string? Source { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reformulation history as supplied by the vendor — e.g. "Formulation Change: 05/22/26".
|
||||||
|
/// Not a reliable modified-date (free text, reformulations only) but a useful signal that a
|
||||||
|
/// product's formula — and therefore its cure specs — may have changed.
|
||||||
|
/// </summary>
|
||||||
|
public string? FormulationChanges { get; set; }
|
||||||
|
|
||||||
/// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary>
|
/// <summary>True when the vendor has discontinued this product. Kept for historical lookups.</summary>
|
||||||
public bool IsDiscontinued { get; set; } = false;
|
public bool IsDiscontinued { get; set; } = false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
namespace PowderCoating.Core.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single source of truth mapping an <see cref="AccountSubType"/> to its parent
|
||||||
|
/// <see cref="AccountType"/>. Each sub-type belongs to exactly one type, so the type can always
|
||||||
|
/// be derived from the sub-type. Used on account create/edit to keep the two fields consistent
|
||||||
|
/// (a mismatched pair would post with the wrong debit/credit sign, since the sign convention keys
|
||||||
|
/// off the sub-type) and anywhere else that needs the canonical pairing.
|
||||||
|
/// </summary>
|
||||||
|
public static class AccountClassification
|
||||||
|
{
|
||||||
|
/// <summary>Returns the parent <see cref="AccountType"/> for a given <see cref="AccountSubType"/>.</summary>
|
||||||
|
public static AccountType TypeForSubType(AccountSubType subType) => subType switch
|
||||||
|
{
|
||||||
|
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
|
||||||
|
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
|
||||||
|
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
|
||||||
|
|
||||||
|
AccountSubType.AccountsPayable or AccountSubType.CreditCard
|
||||||
|
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
|
||||||
|
|
||||||
|
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
|
||||||
|
|
||||||
|
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
|
||||||
|
|
||||||
|
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
|
||||||
|
|
||||||
|
// All expense sub-types (enum values >= 50) and any future additions default to Expense.
|
||||||
|
_ => AccountType.Expense
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a sensible generic <see cref="AccountSubType"/> for a given <see cref="AccountType"/>.
|
||||||
|
/// Used by importers (e.g. QuickBooks) to reconcile a sub-type back to its parent type when the
|
||||||
|
/// source's detail-type couldn't be mapped to a specific sub-type — without this, an unmapped
|
||||||
|
/// liability/equity/revenue account would fall back to <c>Other</c> (an expense-range sub-type)
|
||||||
|
/// and post with the wrong debit/credit sign, since the sign convention keys off sub-type.
|
||||||
|
/// </summary>
|
||||||
|
public static AccountSubType DefaultSubTypeForType(AccountType type) => type switch
|
||||||
|
{
|
||||||
|
AccountType.Asset => AccountSubType.OtherCurrentAsset,
|
||||||
|
AccountType.Liability => AccountSubType.OtherCurrentLiability,
|
||||||
|
AccountType.Equity => AccountSubType.OwnersEquity,
|
||||||
|
AccountType.Revenue => AccountSubType.OtherIncome,
|
||||||
|
AccountType.CostOfGoods => AccountSubType.CostOfGoodsSold,
|
||||||
|
_ => AccountSubType.Other
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -946,6 +946,26 @@ modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
|||||||
.HasForeignKey(i => i.CogsAccountId)
|
.HasForeignKey(i => i.CogsAccountId)
|
||||||
.OnDelete(DeleteBehavior.NoAction);
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
// CompanyPreferences → Default Revenue / COGS / Inventory accounts (nullable, no cascade,
|
||||||
|
// no navigation property — accounts use soft delete and these are config pointers only).
|
||||||
|
modelBuilder.Entity<CompanyPreferences>()
|
||||||
|
.HasOne<Account>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.DefaultRevenueAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CompanyPreferences>()
|
||||||
|
.HasOne<Account>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.DefaultCogsAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
modelBuilder.Entity<CompanyPreferences>()
|
||||||
|
.HasOne<Account>()
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(p => p.DefaultInventoryAccountId)
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
|
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
|
||||||
modelBuilder.Entity<CatalogItem>()
|
modelBuilder.Entity<CatalogItem>()
|
||||||
.HasOne(ci => ci.RevenueAccount)
|
.HasOne(ci => ci.RevenueAccount)
|
||||||
@@ -1511,6 +1531,9 @@ modelBuilder.Entity<Job>()
|
|||||||
modelBuilder.Entity<InventoryItem>()
|
modelBuilder.Entity<InventoryItem>()
|
||||||
.HasIndex(i => new { i.CompanyId, i.SKU })
|
.HasIndex(i => new { i.CompanyId, i.SKU })
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
|
// Filter on IsDeleted so soft-deleted items don't reserve their SKU and block a new
|
||||||
|
// (or re-created) item from reusing it — matching the app's soft-delete semantics.
|
||||||
|
.HasFilter("[IsDeleted] = 0")
|
||||||
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
|
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
|
||||||
|
|
||||||
modelBuilder.Entity<Company>()
|
modelBuilder.Entity<Company>()
|
||||||
|
|||||||
@@ -1359,8 +1359,12 @@ New accounts walk through an 18-step setup wizard to configure company informati
|
|||||||
new() { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
new() { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
new() { AccountNumber = "2100", Name = "Credit Card", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
new() { AccountNumber = "2100", Name = "Credit Card", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
new() { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
new() { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
new() { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
// 2300 = Customer Deposits liability (resolved by number in the deposit GL posting code); payroll is at 2400.
|
||||||
new() { AccountNumber = "2500", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
new() { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
new() { AccountNumber = "2400", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
// 2500 = Gift Certificate Liability (resolved by number in the GC GL posting code); long-term loan moved to 2900.
|
||||||
|
new() { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
new() { AccountNumber = "2900", Name = "Long-Term Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|
||||||
// ── Equity ──────────────────────────────────────────────────────
|
// ── Equity ──────────────────────────────────────────────────────
|
||||||
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
new() { AccountNumber = "3000", Name = "Owner's Equity", AccountType = AccountType.Equity, AccountSubType = AccountSubType.OwnersEquity, IsSystem = true, IsActive = true, CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|||||||
+11375
File diff suppressed because it is too large
Load Diff
+141
@@ -0,0 +1,141 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddColumbiaCatalogIntegrationFields : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Category",
|
||||||
|
table: "PowderCatalogItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "ChemistryType",
|
||||||
|
table: "PowderCatalogItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CureCurvesJson",
|
||||||
|
table: "PowderCatalogItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CureScheduleText",
|
||||||
|
table: "PowderCatalogItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "FormulationChanges",
|
||||||
|
table: "PowderCatalogItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "MilThickness",
|
||||||
|
table: "PowderCatalogItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Source",
|
||||||
|
table: "PowderCatalogItems",
|
||||||
|
type: "nvarchar(max)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "InventoryItems",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Category",
|
||||||
|
table: "PowderCatalogItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ChemistryType",
|
||||||
|
table: "PowderCatalogItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CureCurvesJson",
|
||||||
|
table: "PowderCatalogItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CureScheduleText",
|
||||||
|
table: "PowderCatalogItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "FormulationChanges",
|
||||||
|
table: "PowderCatalogItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "MilThickness",
|
||||||
|
table: "PowderCatalogItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Source",
|
||||||
|
table: "PowderCatalogItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "PowderCatalogItemId",
|
||||||
|
table: "InventoryItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11375
File diff suppressed because it is too large
Load Diff
+88
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class SeedColumbiaSyncSettings : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Seed the SuperAdmin-managed platform settings for the Columbia Coatings catalog sync.
|
||||||
|
// Idempotent so it is safe against a DB where keys were added manually. The API key
|
||||||
|
// itself is NOT here — secrets live in configuration (Columbia:ApiKey), not this table.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncEnabled')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('ColumbiaSyncEnabled','false','Columbia Coatings Sync Enabled','Master switch for the scheduled Columbia Coatings catalog sync. When off, no automatic or manual sync runs regardless of the configured API key.','Integrations');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaSyncIntervalDays')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('ColumbiaSyncIntervalDays','7','Columbia Sync Interval (days)','How many days between automatic Columbia catalog syncs. A full sync is cheap (~25 API calls), so daily (1) or weekly (7) keeps pricing fresh.','Integrations');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncedAt')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('ColumbiaLastSyncedAt',NULL,'Columbia Last Synced At','Timestamp (UTC) of the last successful Columbia catalog sync. Set automatically by the sync job.','Integrations');
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM PlatformSettings WHERE [Key] = 'ColumbiaLastSyncResult')
|
||||||
|
INSERT INTO PlatformSettings ([Key],[Value],[Label],[Description],[GroupName])
|
||||||
|
VALUES ('ColumbiaLastSyncResult',NULL,'Columbia Last Sync Result','Summary of the last Columbia catalog sync run (inserted/updated/discontinued counts or error). Set automatically by the sync job.','Integrations');
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DELETE FROM PlatformSettings WHERE [Key] IN (
|
||||||
|
'ColumbiaSyncEnabled','ColumbiaSyncIntervalDays','ColumbiaLastSyncedAt','ColumbiaLastSyncResult'
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6644));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6651));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 21, 27, 126, DateTimeKind.Utc).AddTicks(6652));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11376
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 FilterInventorySkuUniqueIndexOnSoftDelete : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_InventoryItems_CompanyId_SKU",
|
||||||
|
table: "InventoryItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InventoryItems_CompanyId_SKU",
|
||||||
|
table: "InventoryItems",
|
||||||
|
columns: new[] { "CompanyId", "SKU" },
|
||||||
|
unique: true,
|
||||||
|
filter: "[IsDeleted] = 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_InventoryItems_CompanyId_SKU",
|
||||||
|
table: "InventoryItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(5997));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6002));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 14, 44, 40, 147, DateTimeKind.Utc).AddTicks(6003));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_InventoryItems_CompanyId_SKU",
|
||||||
|
table: "InventoryItems",
|
||||||
|
columns: new[] { "CompanyId", "SKU" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11382
File diff suppressed because it is too large
Load Diff
+81
@@ -0,0 +1,81 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddInventoryCatalogReferencePrice : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<DateTime>(
|
||||||
|
name: "CatalogPriceUpdatedAt",
|
||||||
|
table: "InventoryItems",
|
||||||
|
type: "datetime2",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<decimal>(
|
||||||
|
name: "CatalogReferencePrice",
|
||||||
|
table: "InventoryItems",
|
||||||
|
type: "decimal(18,2)",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CatalogPriceUpdatedAt",
|
||||||
|
table: "InventoryItems");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CatalogReferencePrice",
|
||||||
|
table: "InventoryItems");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4251));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4258));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 16, 33, 6, 662, DateTimeKind.Utc).AddTicks(4259));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11382
File diff suppressed because it is too large
Load Diff
+119
@@ -0,0 +1,119 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class RenameDepositsAccountAddPayroll : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// O1 remediation. Account 2300 has always been used by the deposit GL posting code as the
|
||||||
|
// Customer Deposits liability (resolved by number), but pre-migration tenants still had it
|
||||||
|
// seeded/named "Payroll Liabilities" — so the liability was mislabeled on the balance sheet.
|
||||||
|
// Rename it to "Customer Deposits" and mark it system. Only touch accounts still carrying the
|
||||||
|
// old default name, so a tenant's own rename is preserved.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Accounts
|
||||||
|
SET Name = 'Customer Deposits',
|
||||||
|
Description = 'Deposits received from customers before an invoice is created; cleared when applied to an invoice',
|
||||||
|
IsSystem = 1
|
||||||
|
WHERE AccountNumber = '2300'
|
||||||
|
AND IsDeleted = 0
|
||||||
|
AND Name = 'Payroll Liabilities';
|
||||||
|
");
|
||||||
|
|
||||||
|
// Re-home payroll to a dedicated 2400 account for every company that lacks one, so the chart
|
||||||
|
// still offers a payroll liability without colliding with Customer Deposits at 2300.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted,
|
||||||
|
CurrentBalance, OpeningBalance)
|
||||||
|
SELECT
|
||||||
|
'2400',
|
||||||
|
'Payroll Liabilities',
|
||||||
|
2, -- AccountType.Liability
|
||||||
|
12, -- AccountSubType.OtherCurrentLiability
|
||||||
|
0, -- IsSystem = false
|
||||||
|
1, -- IsActive = true
|
||||||
|
'Payroll taxes and withholdings owed',
|
||||||
|
c.Id,
|
||||||
|
GETUTCDATE(),
|
||||||
|
0, -- IsDeleted = false
|
||||||
|
0, -- CurrentBalance
|
||||||
|
0 -- OpeningBalance
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM Accounts a
|
||||||
|
WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2400'
|
||||||
|
AND a.IsDeleted = 0
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Best-effort reversal. The 2300 rename is intentionally NOT undone: it corrected a mislabeled
|
||||||
|
// account and reverting would re-introduce the bug. Only the empty 2400 accounts this migration
|
||||||
|
// added are soft-deleted (skip any that already carry a balance, i.e. are in use).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Accounts
|
||||||
|
SET IsDeleted = 1
|
||||||
|
WHERE AccountNumber = '2400'
|
||||||
|
AND IsDeleted = 0
|
||||||
|
AND Name = 'Payroll Liabilities'
|
||||||
|
AND CurrentBalance = 0;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2051));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2056));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 17, 19, 26, 43, 161, DateTimeKind.Utc).AddTicks(2057));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+11382
File diff suppressed because it is too large
Load Diff
+123
@@ -0,0 +1,123 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FixGiftCertificateLiabilityAccount : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// O5 remediation. Account 2500 is resolved by number as the Gift Certificate Liability
|
||||||
|
// (GiftCertificatesController), but the default-company chart seeded it as "Long-Term Loan",
|
||||||
|
// so GC obligations were mislabeled there and the AccountingGapsPhase2 GC-liability seed was
|
||||||
|
// skipped by its NOT EXISTS guard.
|
||||||
|
|
||||||
|
// 1) Preserve the long-term loan account: move it to 2900 for any company whose 2500 is still
|
||||||
|
// named "Long-Term Loan" and that lacks a 2900. (Companies onboarded via the per-tenant
|
||||||
|
// seeder already have a 2900 "Business Loan", so the NOT EXISTS guard leaves them alone.)
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
||||||
|
SELECT '2900', 'Long-Term Loan',
|
||||||
|
2, -- AccountType.Liability
|
||||||
|
11, -- AccountSubType.LongTermLiability
|
||||||
|
0, 1, 'Long-term equipment or business loan',
|
||||||
|
c.Id, GETUTCDATE(), 0, 0, 0
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2500' AND a.IsDeleted = 0 AND a.Name = 'Long-Term Loan')
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2900' AND a.IsDeleted = 0);
|
||||||
|
");
|
||||||
|
|
||||||
|
// 2) Relabel the mislabeled 2500 to Gift Certificate Liability (only where it still carries the
|
||||||
|
// old default name, so a user's own rename is preserved). Id / number / balance untouched.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Accounts
|
||||||
|
SET Name = 'Gift Certificate Liability',
|
||||||
|
Description = 'Outstanding gift certificate obligations owed to certificate holders',
|
||||||
|
IsSystem = 1
|
||||||
|
WHERE AccountNumber = '2500' AND IsDeleted = 0 AND Name = 'Long-Term Loan';
|
||||||
|
");
|
||||||
|
|
||||||
|
// 3) Safety net: ensure every company has a 2500 Gift Certificate Liability (covers any tenant
|
||||||
|
// onboarded after AccountingGapsPhase2 ran that never received one — without it GC GL no-ops).
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
INSERT INTO Accounts
|
||||||
|
(AccountNumber, Name, AccountType, AccountSubType,
|
||||||
|
IsSystem, IsActive, Description,
|
||||||
|
CompanyId, CreatedAt, IsDeleted, CurrentBalance, OpeningBalance)
|
||||||
|
SELECT '2500', 'Gift Certificate Liability',
|
||||||
|
2, -- AccountType.Liability
|
||||||
|
12, -- AccountSubType.OtherCurrentLiability
|
||||||
|
1, 1, 'Outstanding gift certificate obligations owed to certificate holders',
|
||||||
|
c.Id, GETUTCDATE(), 0, 0, 0
|
||||||
|
FROM Companies c
|
||||||
|
WHERE c.IsDeleted = 0
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM Accounts a WHERE a.CompanyId = c.Id
|
||||||
|
AND a.AccountNumber = '2500' AND a.IsDeleted = 0);
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Best-effort reversal: the 2500 relabel is intentionally NOT undone (reverting would
|
||||||
|
// re-introduce the mislabel), and 2500 rows are left in place since most pre-date this
|
||||||
|
// migration. Only soft-delete the empty 2900 accounts this migration added.
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
UPDATE Accounts SET IsDeleted = 1
|
||||||
|
WHERE AccountNumber = '2900' AND IsDeleted = 0 AND Name = 'Long-Term Loan' AND CurrentBalance = 0;
|
||||||
|
");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7611));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7618));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 19, 23, 31, 4, 905, DateTimeKind.Utc).AddTicks(7619));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+11412
File diff suppressed because it is too large
Load Diff
+151
@@ -0,0 +1,151 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddCompanyDefaultGlAccounts : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
type: "int",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515));
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultCogsAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultInventoryAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultRevenueAccountId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultCogsAccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultInventoryAccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences",
|
||||||
|
column: "DefaultRevenueAccountId",
|
||||||
|
principalTable: "Accounts",
|
||||||
|
principalColumn: "Id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_CompanyPreferences_Accounts_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_CompanyPreferences_DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultCogsAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultInventoryAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "DefaultRevenueAccountId",
|
||||||
|
table: "CompanyPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 1,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3976));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 2,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3981));
|
||||||
|
|
||||||
|
migrationBuilder.UpdateData(
|
||||||
|
table: "PricingTiers",
|
||||||
|
keyColumn: "Id",
|
||||||
|
keyValue: 3,
|
||||||
|
column: "CreatedAt",
|
||||||
|
value: new DateTime(2026, 6, 20, 0, 29, 46, 909, DateTimeKind.Utc).AddTicks(3982));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2185,6 +2185,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("CreatedBy")
|
b.Property<string>("CreatedBy")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultCogsAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("DefaultCurrency")
|
b.Property<string>("DefaultCurrency")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2193,6 +2196,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultInventoryAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("DefaultJobPriority")
|
b.Property<string>("DefaultJobPriority")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2204,6 +2210,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<int>("DefaultQuoteValidityDays")
|
b.Property<int>("DefaultQuoteValidityDays")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
|
b.Property<int?>("DefaultRevenueAccountId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<string>("DefaultTimeFormat")
|
b.Property<string>("DefaultTimeFormat")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -2380,6 +2389,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.HasIndex("CompanyId")
|
b.HasIndex("CompanyId")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("DefaultCogsAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("DefaultInventoryAccountId");
|
||||||
|
|
||||||
|
b.HasIndex("DefaultRevenueAccountId");
|
||||||
|
|
||||||
b.ToTable("CompanyPreferences");
|
b.ToTable("CompanyPreferences");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4075,6 +4090,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<decimal>("AverageCost")
|
b.Property<decimal>("AverageCost")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("CatalogPriceUpdatedAt")
|
||||||
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<decimal?>("CatalogReferencePrice")
|
||||||
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
b.Property<string>("Category")
|
b.Property<string>("Category")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
@@ -4173,6 +4194,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<int?>("PowderCatalogItemId")
|
||||||
|
.HasColumnType("int");
|
||||||
|
|
||||||
b.Property<int?>("PrimaryVendorId")
|
b.Property<int?>("PrimaryVendorId")
|
||||||
.HasColumnType("int");
|
.HasColumnType("int");
|
||||||
|
|
||||||
@@ -4241,7 +4265,8 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
|
|
||||||
b.HasIndex("CompanyId", "SKU")
|
b.HasIndex("CompanyId", "SKU")
|
||||||
.IsUnique()
|
.IsUnique()
|
||||||
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
|
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU")
|
||||||
|
.HasFilter("[IsDeleted] = 0");
|
||||||
|
|
||||||
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
|
b.HasIndex("CompanyId", "QuantityOnHand", "ReorderPoint")
|
||||||
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
|
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
|
||||||
@@ -6936,6 +6961,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("ApplicationGuideUrl")
|
b.Property<string>("ApplicationGuideUrl")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("Category")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("ChemistryType")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ColorFamilies")
|
b.Property<string>("ColorFamilies")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -6949,6 +6980,12 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("CureCurvesJson")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("CureScheduleText")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<decimal?>("CureTemperatureF")
|
b.Property<decimal?>("CureTemperatureF")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -6961,6 +6998,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<string>("Finish")
|
b.Property<string>("Finish")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
|
b.Property<string>("FormulationChanges")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("ImageUrl")
|
b.Property<string>("ImageUrl")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -6973,6 +7013,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
b.Property<DateTime?>("LastSyncedAt")
|
b.Property<DateTime?>("LastSyncedAt")
|
||||||
.HasColumnType("datetime2");
|
.HasColumnType("datetime2");
|
||||||
|
|
||||||
|
b.Property<string>("MilThickness")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<string>("PriceTiersJson")
|
b.Property<string>("PriceTiersJson")
|
||||||
.HasColumnType("nvarchar(max)");
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
@@ -6989,6 +7032,9 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("nvarchar(450)");
|
.HasColumnType("nvarchar(450)");
|
||||||
|
|
||||||
|
b.Property<string>("Source")
|
||||||
|
.HasColumnType("nvarchar(max)");
|
||||||
|
|
||||||
b.Property<decimal?>("SpecificGravity")
|
b.Property<decimal?>("SpecificGravity")
|
||||||
.HasColumnType("decimal(18,2)");
|
.HasColumnType("decimal(18,2)");
|
||||||
|
|
||||||
@@ -7210,7 +7256,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4191),
|
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4507),
|
||||||
Description = "Standard pricing for regular customers",
|
Description = "Standard pricing for regular customers",
|
||||||
DiscountPercent = 0m,
|
DiscountPercent = 0m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7221,7 +7267,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 2,
|
Id = 2,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4196),
|
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4514),
|
||||||
Description = "5% discount for preferred customers",
|
Description = "5% discount for preferred customers",
|
||||||
DiscountPercent = 5m,
|
DiscountPercent = 5m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -7232,7 +7278,7 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
{
|
{
|
||||||
Id = 3,
|
Id = 3,
|
||||||
CompanyId = 0,
|
CompanyId = 0,
|
||||||
CreatedAt = new DateTime(2026, 6, 14, 1, 21, 46, 131, DateTimeKind.Utc).AddTicks(4197),
|
CreatedAt = new DateTime(2026, 6, 20, 13, 49, 14, 564, DateTimeKind.Utc).AddTicks(4515),
|
||||||
Description = "10% discount for premium customers",
|
Description = "10% discount for premium customers",
|
||||||
DiscountPercent = 10m,
|
DiscountPercent = 10m,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
@@ -9549,6 +9595,21 @@ namespace PowderCoating.Infrastructure.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DefaultCogsAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DefaultInventoryAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
|
b.HasOne("PowderCoating.Core.Entities.Account", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("DefaultRevenueAccountId")
|
||||||
|
.OnDelete(DeleteBehavior.NoAction);
|
||||||
|
|
||||||
b.Navigation("Company");
|
b.Navigation("Company");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using PowderCoating.Application.Constants;
|
||||||
|
using PowderCoating.Application.DTOs.Columbia;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Services.Columbia;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a Columbia Coatings API product onto our platform <see cref="PowderCatalogItem"/>.
|
||||||
|
/// Pure, static, side-effect free so the tricky bits (manufacturer derivation, the free-text
|
||||||
|
/// cure-schedule parser, HTML stripping) can be unit tested directly against captured fixtures.
|
||||||
|
/// <para>
|
||||||
|
/// Columbia is a distributor reselling multiple brands, so <see cref="PowderCatalogItem.VendorName"/>
|
||||||
|
/// holds the DERIVED manufacturer (PPG / KP Pigments / Columbia) while
|
||||||
|
/// <see cref="PowderCatalogItem.Source"/> records the feed ("Columbia Coatings API") for
|
||||||
|
/// right-to-delete purges. The vendor's own categories/tags are read here only to derive the
|
||||||
|
/// manufacturer and additive flag — they are never stored raw.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static class ColumbiaCatalogMapper
|
||||||
|
{
|
||||||
|
/// <summary>A single parsed cure curve — hold <see cref="Minutes"/> at <see cref="TempF"/>.</summary>
|
||||||
|
public readonly record struct CureCurve(int TempF, int Minutes);
|
||||||
|
|
||||||
|
// Resin chemistries that mean "polyester + TGIC" but arrive formatted three different ways.
|
||||||
|
private static readonly Regex PolyesterTgic =
|
||||||
|
new(@"^\s*(polyester\s*[/ ]\s*tgic|tgic\s*[/ ]\s*polyester)\s*$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
// "10 minutes @ 400°F", "7 minutes at 375 F", "Metal Temperature: 10 minutes at 400°F (204°C)".
|
||||||
|
// Degree glyph is optional and may be ° (U+00B0), ˚ (U+02DA), or º (U+00BA).
|
||||||
|
private static readonly Regex CureCurveRegex =
|
||||||
|
new(@"(\d+)\s*min(?:ute)?s?\.?\s*(?:@|at)\s*(\d{2,3})\s*[°˚º]?\s*F",
|
||||||
|
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly Regex HtmlTag = new("<[^>]+>", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex WhitespaceRun = new(@"\s{2,}", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions JsonOut = new() { WriteIndented = false };
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for products that should not be in the powder catalog as standalone colors:
|
||||||
|
/// physical swatch cards (not powder at all), and tester (4 oz) / sample (5 lb) listings that
|
||||||
|
/// are just smaller SIZES of a parent powder that already exists as its own product. Detected
|
||||||
|
/// by specific SKU suffixes (-SW / -04) and unambiguous name markers ("SWATCH", "Tester",
|
||||||
|
/// "Sample ("). The sample-size "-S" SKU suffix is intentionally NOT used on its own — the
|
||||||
|
/// "Sample (" name marker catches every sample without risking a real SKU that ends in -S.
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsExcludedProduct(ColumbiaProduct p)
|
||||||
|
{
|
||||||
|
var sku = p.Sku ?? string.Empty;
|
||||||
|
if (sku.EndsWith("-SW", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sku.EndsWith("-04", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var name = p.Name ?? string.Empty;
|
||||||
|
return name.Contains("SWATCH", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("Tester", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| name.Contains("Sample (", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Maps a Columbia product into a fully populated (unsaved) catalog item.</summary>
|
||||||
|
public static PowderCatalogItem Map(ColumbiaProduct p)
|
||||||
|
{
|
||||||
|
var curves = ParseCureCurves(p.CureSchedule);
|
||||||
|
var primary = curves.Count > 0 ? curves[0] : (CureCurve?)null;
|
||||||
|
|
||||||
|
return new PowderCatalogItem
|
||||||
|
{
|
||||||
|
VendorName = DeriveManufacturer(p),
|
||||||
|
Sku = p.Sku.Trim(),
|
||||||
|
ColorName = p.Name.Trim(),
|
||||||
|
Source = ColumbiaIntegrationConstants.SourceName,
|
||||||
|
Category = IsAdditive(p) ? ColumbiaIntegrationConstants.CategoryPowderAdditives : null,
|
||||||
|
|
||||||
|
Description = StripHtml(p.Description),
|
||||||
|
UnitPrice = ParseBasePrice(p),
|
||||||
|
PriceTiersJson = BuildPriceTiersJson(p),
|
||||||
|
|
||||||
|
ImageUrl = NullIfBlank(p.FeaturedImage?.Src),
|
||||||
|
SdsUrl = NullIfBlank(p.SafetyDataSheet),
|
||||||
|
TdsUrl = NullIfBlank(p.TechnicalDataSheet),
|
||||||
|
ApplicationGuideUrl = NullIfBlank(FirstNonBlank(p.ProductFlyer, p.ProductBrochure)),
|
||||||
|
ProductUrl = NullIfBlank(p.Permalink),
|
||||||
|
|
||||||
|
ChemistryType = NormalizeChemistry(p.Type),
|
||||||
|
MilThickness = NullIfBlank(p.MilThickness),
|
||||||
|
CureScheduleText = NullIfBlank(p.CureSchedule),
|
||||||
|
CureCurvesJson = curves.Count > 0 ? JsonSerializer.Serialize(curves, JsonOut) : null,
|
||||||
|
CureTemperatureF = primary?.TempF,
|
||||||
|
CureTimeMinutes = primary?.Minutes,
|
||||||
|
RequiresClearCoat = DetectRequiresClearCoat(p),
|
||||||
|
|
||||||
|
ColorFamilies = BuildColorFamilies(p),
|
||||||
|
FormulationChanges = NullIfBlank(p.FormulationDateChanges),
|
||||||
|
|
||||||
|
// Coverage / specific gravity / transfer efficiency are not in the API — left null for
|
||||||
|
// lazy TDS/AI enrichment on first use. IsDiscontinued is handled by the sync sweep.
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Manufacturer derivation ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Derives the manufacturer from the product's taxonomy/SKU. Columbia resells PPG powders and
|
||||||
|
/// KP Pigments additives through the same feed; everything else is Columbia's own brand.
|
||||||
|
/// </summary>
|
||||||
|
public static string DeriveManufacturer(ColumbiaProduct p)
|
||||||
|
{
|
||||||
|
if (IsKpPigments(p))
|
||||||
|
return ColumbiaIntegrationConstants.ManufacturerKp;
|
||||||
|
if (IsPpg(p))
|
||||||
|
return ColumbiaIntegrationConstants.ManufacturerPpg;
|
||||||
|
return ColumbiaIntegrationConstants.ManufacturerColumbia;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsKpPigments(ColumbiaProduct p) =>
|
||||||
|
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| CategoryStartsWith(p, "KP");
|
||||||
|
|
||||||
|
private static bool IsPpg(ColumbiaProduct p) =>
|
||||||
|
CategoryStartsWith(p, "PPG");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// True for pigments/additives sold by weight (grams) rather than sprayed powders. These get
|
||||||
|
/// forced into the "Powder Additives" category. Keyed off the broad Additives category and the
|
||||||
|
/// ADD- SKU prefix, not just the KP brand (there are ~98 non-KP additives).
|
||||||
|
/// </summary>
|
||||||
|
public static bool IsAdditive(ColumbiaProduct p) =>
|
||||||
|
p.Sku.StartsWith("ADD-", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| p.Categories.Any(c => c.Name.Equals("Additives", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|| CategoryStartsWith(p, "KP");
|
||||||
|
|
||||||
|
private static bool CategoryStartsWith(ColumbiaProduct p, string prefix) =>
|
||||||
|
p.Categories.Any(c => c.Name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
// ── Pricing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Base unit price = the top-level <c>price</c> (falling back to <c>regular_price</c>). For
|
||||||
|
/// variable products the parent <c>price</c> already carries the lead variant's price, while
|
||||||
|
/// <c>regular_price</c> is often "0", so price is preferred.
|
||||||
|
/// </summary>
|
||||||
|
public static decimal ParseBasePrice(ColumbiaProduct p)
|
||||||
|
{
|
||||||
|
if (TryParseMoney(p.Price, out var price) && price > 0)
|
||||||
|
return price;
|
||||||
|
if (TryParseMoney(p.RegularPrice, out var regular) && regular > 0)
|
||||||
|
return regular;
|
||||||
|
|
||||||
|
// Variable product with a zero parent price: fall back to the lowest variant price.
|
||||||
|
var variantPrices = (p.VariationPricing ?? new List<ColumbiaVariationPricing>())
|
||||||
|
.Select(v => TryParseMoney(v.Price, out var vp) ? vp : 0m)
|
||||||
|
.Where(v => v > 0)
|
||||||
|
.ToList();
|
||||||
|
return variantPrices.Count > 0 ? variantPrices.Min() : 0m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures quantity-break / variant pricing as JSON for later use. For variable products this
|
||||||
|
/// is the per-variant pricing (Bulk vs 1 lb Bags, gram sizes); for simple products it's the
|
||||||
|
/// tiered_pricing object. Null when neither is present.
|
||||||
|
/// </summary>
|
||||||
|
public static string? BuildPriceTiersJson(ColumbiaProduct p)
|
||||||
|
{
|
||||||
|
if (p.VariationPricing is { Count: > 0 })
|
||||||
|
return JsonSerializer.Serialize(p.VariationPricing, JsonOut);
|
||||||
|
|
||||||
|
if (p.TieredPricing is { ValueKind: JsonValueKind.Object } tiered)
|
||||||
|
{
|
||||||
|
// Only keep it if it actually carries tiers (avoid storing empty {type,...} shells).
|
||||||
|
if (tiered.TryGetProperty("tiers", out var tiers)
|
||||||
|
&& tiers.ValueKind == JsonValueKind.Array
|
||||||
|
&& tiers.GetArrayLength() > 0)
|
||||||
|
{
|
||||||
|
return tiered.GetRawText();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseMoney(string? s, out decimal value) =>
|
||||||
|
decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value);
|
||||||
|
|
||||||
|
// ── Cure schedule parsing ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts every "N minutes at/@ TTT°F" curve from a free-text cure schedule, in document
|
||||||
|
/// order. The first is treated as the primary/standard curve; the rest are alternate (often
|
||||||
|
/// lower-temperature) curves preserved for heat-sensitive substrates. Returns an empty list for
|
||||||
|
/// schedules with no parseable temp/time pair (partial-cure / clear-coat instructions).
|
||||||
|
/// </summary>
|
||||||
|
public static List<CureCurve> ParseCureCurves(string? cureSchedule)
|
||||||
|
{
|
||||||
|
var result = new List<CureCurve>();
|
||||||
|
if (string.IsNullOrWhiteSpace(cureSchedule))
|
||||||
|
return result;
|
||||||
|
|
||||||
|
foreach (Match m in CureCurveRegex.Matches(cureSchedule))
|
||||||
|
{
|
||||||
|
if (int.TryParse(m.Groups[1].Value, out var minutes)
|
||||||
|
&& int.TryParse(m.Groups[2].Value, out var tempF)
|
||||||
|
&& tempF is >= 150 and <= 600 // sanity: real cure temps
|
||||||
|
&& minutes is > 0 and <= 120)
|
||||||
|
{
|
||||||
|
var curve = new CureCurve(tempF, minutes);
|
||||||
|
if (!result.Contains(curve))
|
||||||
|
result.Add(curve);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Powders that genuinely REQUIRE a clear coat say so explicitly. A casual "apply a clear coat
|
||||||
|
// for added durability" must NOT trip this — that over-flagged ~half the catalog and would pad
|
||||||
|
// quotes with unnecessary clear-coat steps.
|
||||||
|
private static readonly string[] RequiresClearPhrases =
|
||||||
|
{
|
||||||
|
"requires a clear", "requires clear", "require a clear",
|
||||||
|
"must be clear coated", "must be cleared", "needs a clear",
|
||||||
|
"clear coat is required", "clear coat required", "requires a clearcoat",
|
||||||
|
"requires a top coat", "clear coat to activate", "clear coat to achieve",
|
||||||
|
"requires a clear coat",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flags powders that genuinely need a clear coat: multi-step partial-cure (Illusion-style)
|
||||||
|
/// schedules, Columbia's named "Illusion" line, or explicit requirement phrasing. Casual
|
||||||
|
/// "you can clear coat this" mentions are intentionally ignored.
|
||||||
|
/// </summary>
|
||||||
|
public static bool DetectRequiresClearCoat(ColumbiaProduct p)
|
||||||
|
{
|
||||||
|
var cure = p.CureSchedule ?? string.Empty;
|
||||||
|
var name = p.Name ?? string.Empty;
|
||||||
|
|
||||||
|
// Partial-cure / multi-step instructions are the "apply this, then clear" case.
|
||||||
|
if (cure.Contains("partial cure", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// Columbia's Illusion line needs a clear top coat to develop the effect.
|
||||||
|
if (name.Contains("Illusion", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
var text = $"{name} {cure} {p.Description}";
|
||||||
|
return RequiresClearPhrases.Any(phrase => text.Contains(phrase, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Misc field helpers ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>Joins the color-group taxonomy ({name} entries) into a comma-separated families string.</summary>
|
||||||
|
public static string? BuildColorFamilies(ColumbiaProduct p)
|
||||||
|
{
|
||||||
|
var groups = p.PaColorGroup.Select(g => g.Name.Trim()).Where(n => n.Length > 0).Distinct().ToList();
|
||||||
|
|
||||||
|
if (groups.Count == 0)
|
||||||
|
{
|
||||||
|
// Fall back to the "Color Group" attribute options when the taxonomy is empty.
|
||||||
|
groups = p.Attributes
|
||||||
|
.Where(a => a.Name.Equals("Color Group", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.SelectMany(a => a.Options.Select(o => o.Name.Trim()))
|
||||||
|
.Where(n => n.Length > 0)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.Count > 0 ? string.Join(",", groups) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Normalizes resin chemistry — trims, and collapses the three Polyester/TGIC spellings.</summary>
|
||||||
|
public static string? NormalizeChemistry(string? type)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(type))
|
||||||
|
return null;
|
||||||
|
var trimmed = type.Trim();
|
||||||
|
return PolyesterTgic.IsMatch(trimmed) ? "Polyester/TGIC" : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Strips HTML tags/entities from a description and collapses whitespace to plain text.</summary>
|
||||||
|
public static string? StripHtml(string? html)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(html))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var text = HtmlTag.Replace(html, " ");
|
||||||
|
text = WebUtility.HtmlDecode(text);
|
||||||
|
text = text.Replace("\r", " ").Replace("\n", " ").Replace("\t", " ");
|
||||||
|
text = WhitespaceRun.Replace(text, " ").Trim();
|
||||||
|
return text.Length > 0 ? text : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NullIfBlank(string? s) => string.IsNullOrWhiteSpace(s) ? null : s.Trim();
|
||||||
|
|
||||||
|
private static string? FirstNonBlank(params string?[] values) =>
|
||||||
|
values.FirstOrDefault(v => !string.IsNullOrWhiteSpace(v));
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Globalization;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PowderCoating.Application.Constants;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Services.Columbia;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Full Columbia Coatings catalog sync: pages the API, maps each product, upserts via the shared
|
||||||
|
/// <see cref="IPowderCatalogUpsertService"/>, then reconciles discontinuations against the complete
|
||||||
|
/// pull. The discontinuation sweep runs ONLY after a successful full fetch — a partial pull (any
|
||||||
|
/// page failure throws from the client) aborts before the sweep so a transient error can never mass
|
||||||
|
/// flag the catalog as discontinued.
|
||||||
|
/// </summary>
|
||||||
|
public class ColumbiaCatalogSyncService : IColumbiaCatalogSyncService
|
||||||
|
{
|
||||||
|
private readonly IColumbiaCoatingsApiClient _client;
|
||||||
|
private readonly IPowderCatalogUpsertService _upsert;
|
||||||
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly IPlatformSettingsService _settings;
|
||||||
|
private readonly ILogger<ColumbiaCatalogSyncService> _logger;
|
||||||
|
|
||||||
|
public ColumbiaCatalogSyncService(
|
||||||
|
IColumbiaCoatingsApiClient client,
|
||||||
|
IPowderCatalogUpsertService upsert,
|
||||||
|
IUnitOfWork unitOfWork,
|
||||||
|
IPlatformSettingsService settings,
|
||||||
|
ILogger<ColumbiaCatalogSyncService> logger)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_upsert = upsert;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_settings = settings;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ColumbiaSyncResult> RunSyncAsync(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new ColumbiaSyncResult { StartedAt = DateTime.UtcNow };
|
||||||
|
var stopwatch = Stopwatch.StartNew();
|
||||||
|
|
||||||
|
if (!_client.IsConfigured)
|
||||||
|
{
|
||||||
|
result.ErrorMessage = "Columbia API key is not configured.";
|
||||||
|
await RecordResultAsync(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Full pull — throws on any page failure, which we treat as an incomplete sync.
|
||||||
|
var products = await _client.GetAllProductsAsync(cancellationToken);
|
||||||
|
result.TotalFetched = products.Count;
|
||||||
|
|
||||||
|
// Map and de-duplicate by (VendorName, SKU) in case the feed repeats a SKU.
|
||||||
|
// Exclude swatch cards and tester/sample size-variants — not standalone powder colors.
|
||||||
|
var mapped = products
|
||||||
|
.Where(p => !ColumbiaCatalogMapper.IsExcludedProduct(p))
|
||||||
|
.Select(ColumbiaCatalogMapper.Map)
|
||||||
|
.Where(m => !string.IsNullOrWhiteSpace(m.Sku))
|
||||||
|
.GroupBy(m => $"{m.VendorName}|{m.Sku}", StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(g => g.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var upsertResult = await _upsert.UpsertAsync(mapped, result.StartedAt, cancellationToken);
|
||||||
|
result.Inserted = upsertResult.Inserted;
|
||||||
|
result.Updated = upsertResult.Updated;
|
||||||
|
result.Unchanged = upsertResult.Unchanged;
|
||||||
|
result.Skipped = upsertResult.Skipped;
|
||||||
|
|
||||||
|
// Remove any excluded records (swatches) that were synced before the exclusion existed,
|
||||||
|
// so they're deleted outright rather than lingering as "discontinued" powders.
|
||||||
|
await RemoveExcludedRecordsAsync();
|
||||||
|
|
||||||
|
// Complete pull succeeded — safe to reconcile discontinuations.
|
||||||
|
var incomingKeys = mapped
|
||||||
|
.Select(m => $"{m.VendorName}|{m.Sku}")
|
||||||
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
(result.Discontinued, result.Reactivated) =
|
||||||
|
await ReconcileDiscontinuationsAsync(incomingKeys, result.StartedAt);
|
||||||
|
|
||||||
|
result.Success = true;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Columbia catalog sync failed; skipping discontinuation sweep.");
|
||||||
|
result.Success = false;
|
||||||
|
result.ErrorMessage = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopwatch.Stop();
|
||||||
|
result.Duration = stopwatch.Elapsed;
|
||||||
|
await RecordResultAsync(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Flags catalog items sourced from Columbia that were NOT in this complete pull as discontinued,
|
||||||
|
/// and reactivates any previously-discontinued item that has reappeared. Returns (discontinued,
|
||||||
|
/// reactivated) counts.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<(int Discontinued, int Reactivated)> ReconcileDiscontinuationsAsync(
|
||||||
|
HashSet<string> incomingKeys, DateTime runTimestamp)
|
||||||
|
{
|
||||||
|
var sourced = await _unitOfWork.PowderCatalog.FindAsync(
|
||||||
|
p => p.Source == ColumbiaIntegrationConstants.SourceName);
|
||||||
|
|
||||||
|
var discontinued = 0;
|
||||||
|
var reactivated = 0;
|
||||||
|
|
||||||
|
foreach (var item in sourced)
|
||||||
|
{
|
||||||
|
var present = incomingKeys.Contains($"{item.VendorName}|{item.Sku}");
|
||||||
|
|
||||||
|
if (!present && !item.IsDiscontinued)
|
||||||
|
{
|
||||||
|
item.IsDiscontinued = true;
|
||||||
|
item.UpdatedAt = runTimestamp;
|
||||||
|
await _unitOfWork.PowderCatalog.UpdateAsync(item);
|
||||||
|
discontinued++;
|
||||||
|
}
|
||||||
|
else if (present && item.IsDiscontinued)
|
||||||
|
{
|
||||||
|
item.IsDiscontinued = false;
|
||||||
|
item.UpdatedAt = runTimestamp;
|
||||||
|
await _unitOfWork.PowderCatalog.UpdateAsync(item);
|
||||||
|
reactivated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (discontinued > 0 || reactivated > 0)
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return (discontinued, reactivated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes Columbia-sourced catalog rows that should not be in the catalog (swatch cards and
|
||||||
|
/// tester/sample size-variants). Mirrors <see cref="ColumbiaCatalogMapper.IsExcludedProduct"/>
|
||||||
|
/// on the stored columns. A no-op once the catalog is clean; guards against records synced
|
||||||
|
/// before the exclusion rule and ensures excluded items are removed, not flagged discontinued.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RemoveExcludedRecordsAsync()
|
||||||
|
{
|
||||||
|
var excluded = (await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||||
|
p.Source == ColumbiaIntegrationConstants.SourceName
|
||||||
|
&& (p.Sku.EndsWith("-SW")
|
||||||
|
|| p.Sku.EndsWith("-04")
|
||||||
|
|| p.ColorName.Contains("SWATCH")
|
||||||
|
|| p.ColorName.Contains("Tester")
|
||||||
|
|| p.ColorName.Contains("Sample (")))).ToList();
|
||||||
|
|
||||||
|
if (excluded.Count == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
foreach (var e in excluded)
|
||||||
|
await _unitOfWork.PowderCatalog.DeleteAsync(e);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Columbia sync: removed {Count} excluded record(s) (swatch/tester/sample) from the catalog.", excluded.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Persists the run outcome to the last-synced / last-result platform settings.</summary>
|
||||||
|
private async Task RecordResultAsync(ColumbiaSyncResult result)
|
||||||
|
{
|
||||||
|
if (result.Success)
|
||||||
|
{
|
||||||
|
await _settings.SetAsync(
|
||||||
|
ColumbiaIntegrationConstants.SettingLastSyncedAt,
|
||||||
|
result.StartedAt.ToString("O", CultureInfo.InvariantCulture),
|
||||||
|
updatedBy: "Columbia Sync");
|
||||||
|
}
|
||||||
|
|
||||||
|
await _settings.SetAsync(
|
||||||
|
ColumbiaIntegrationConstants.SettingLastResult,
|
||||||
|
result.Summary,
|
||||||
|
updatedBy: "Columbia Sync");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PowderCoating.Application.Constants;
|
||||||
|
using PowderCoating.Application.DTOs.Columbia;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTTP client for the Columbia Coatings product catalog API. Reads the API key and base URL from
|
||||||
|
/// configuration (<c>Columbia:ApiKey</c> / <c>Columbia:BaseUrl</c>), sends the <c>X-API-Key</c>
|
||||||
|
/// header, and pages the catalog via <c>GET /products</c>. Honors the documented rate limit
|
||||||
|
/// (120 requests / 60s) by retrying on HTTP 429 after the <c>Retry-After</c> interval.
|
||||||
|
/// </summary>
|
||||||
|
public class ColumbiaCoatingsApiClient : IColumbiaCoatingsApiClient
|
||||||
|
{
|
||||||
|
private const int MaxRetriesPer429 = 5;
|
||||||
|
private const int DefaultRetryAfterSeconds = 5;
|
||||||
|
private const int MaxPagesSafetyCap = 1000; // guards against a server that never reports last page
|
||||||
|
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly IConfiguration _config;
|
||||||
|
private readonly ILogger<ColumbiaCoatingsApiClient> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Columbia returns snake_case JSON; the snake-case naming policy binds it to our PascalCase DTOs
|
||||||
|
/// without per-property attributes. Case-insensitive as a belt-and-braces fallback.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
|
{
|
||||||
|
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
Converters = { new ColumbiaImageJsonConverter() },
|
||||||
|
};
|
||||||
|
|
||||||
|
public ColumbiaCoatingsApiClient(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
IConfiguration config,
|
||||||
|
ILogger<ColumbiaCoatingsApiClient> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ApiKey => _config[ColumbiaIntegrationConstants.ConfigApiKey];
|
||||||
|
|
||||||
|
private string BaseUrl =>
|
||||||
|
(_config[ColumbiaIntegrationConstants.ConfigBaseUrl] ?? ColumbiaIntegrationConstants.DefaultBaseUrl)
|
||||||
|
.TrimEnd('/');
|
||||||
|
|
||||||
|
private string ApiBasePath =>
|
||||||
|
(_config[ColumbiaIntegrationConstants.ConfigApiBasePath] ?? ColumbiaIntegrationConstants.DefaultApiBasePath)
|
||||||
|
.Trim('/');
|
||||||
|
|
||||||
|
/// <summary>Fully-qualified products endpoint: host + configurable API base path + /products.</summary>
|
||||||
|
private string ProductsUrl => $"{BaseUrl}/{ApiBasePath}{ColumbiaIntegrationConstants.ProductsResource}";
|
||||||
|
|
||||||
|
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ColumbiaProductsResponse> GetProductsPageAsync(
|
||||||
|
int page, int perPage, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureConfigured();
|
||||||
|
perPage = Math.Clamp(perPage, 1, ColumbiaIntegrationConstants.MaxPerPage);
|
||||||
|
var url = $"{ProductsUrl}?page={page}&per_page={perPage}";
|
||||||
|
|
||||||
|
var json = await SendWithRetryAsync(url, $"page {page}", cancellationToken);
|
||||||
|
if (json == null)
|
||||||
|
return new ColumbiaProductsResponse();
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions) ?? new ColumbiaProductsResponse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ColumbiaProduct?> GetProductBySkuAsync(string sku, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureConfigured();
|
||||||
|
if (string.IsNullOrWhiteSpace(sku))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var url = $"{ProductsUrl}?sku={Uri.EscapeDataString(sku)}&per_page=1";
|
||||||
|
var json = await SendWithRetryAsync(url, $"sku {sku}", cancellationToken);
|
||||||
|
if (json == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var response = JsonSerializer.Deserialize<ColumbiaProductsResponse>(json, JsonOptions);
|
||||||
|
return response?.Items.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<ColumbiaProduct?> GetProductByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureConfigured();
|
||||||
|
// The by-id endpoint returns a bare product object (not the {items,pagination} envelope).
|
||||||
|
var url = $"{ProductsUrl}/{id}";
|
||||||
|
var json = await SendWithRetryAsync(url, $"id {id}", cancellationToken);
|
||||||
|
if (json == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<ColumbiaProduct>(json, JsonOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IReadOnlyList<ColumbiaProduct>> GetAllProductsAsync(
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
EnsureConfigured();
|
||||||
|
|
||||||
|
var all = new List<ColumbiaProduct>();
|
||||||
|
|
||||||
|
for (var page = 1; page <= MaxPagesSafetyCap; page++)
|
||||||
|
{
|
||||||
|
var response = await GetProductsPageAsync(page, ColumbiaIntegrationConstants.MaxPerPage, cancellationToken);
|
||||||
|
|
||||||
|
if (response.Items.Count == 0)
|
||||||
|
break;
|
||||||
|
|
||||||
|
all.AddRange(response.Items);
|
||||||
|
|
||||||
|
// Stop when the pagination block says we've reached the last page.
|
||||||
|
if (response.Pagination is { TotalPages: > 0 } p && page >= p.TotalPages)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Columbia API: retrieved {Count} products across paged requests.", all.Count);
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Throws when no API key is configured so callers fail fast rather than 401.</summary>
|
||||||
|
private void EnsureConfigured()
|
||||||
|
{
|
||||||
|
if (!IsConfigured)
|
||||||
|
throw new InvalidOperationException("Columbia Coatings API key is not configured (Columbia:ApiKey).");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Issues a GET with the API key header and returns the response body. Retries on HTTP 429
|
||||||
|
/// (honoring Retry-After) up to <see cref="MaxRetriesPer429"/>. Returns null on 404 so
|
||||||
|
/// single-product lookups surface "not found" without throwing; throws on any other non-success.
|
||||||
|
/// <paramref name="describe"/> is a short label (e.g. "page 3", "sku ABC") for log/error context.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string?> SendWithRetryAsync(string url, string describe, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
for (var attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||||
|
request.Headers.Add("X-API-Key", ApiKey);
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient();
|
||||||
|
using var response = await client.SendAsync(request, cancellationToken);
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.TooManyRequests)
|
||||||
|
{
|
||||||
|
if (attempt > MaxRetriesPer429)
|
||||||
|
throw new HttpRequestException(
|
||||||
|
$"Columbia API still rate-limiting after {MaxRetriesPer429} retries ({describe}).");
|
||||||
|
|
||||||
|
var delaySeconds = GetRetryAfterSeconds(response) ?? DefaultRetryAfterSeconds;
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Columbia API returned 429 ({Describe}, attempt {Attempt}); waiting {Delay}s before retry.",
|
||||||
|
describe, attempt, delaySeconds);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(delaySeconds), cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses the <c>Retry-After</c> header (delta-seconds or HTTP-date form) into whole seconds,
|
||||||
|
/// or null when absent/unparseable so the caller can fall back to a default.
|
||||||
|
/// </summary>
|
||||||
|
private static int? GetRetryAfterSeconds(HttpResponseMessage response)
|
||||||
|
{
|
||||||
|
var retryAfter = response.Headers.RetryAfter;
|
||||||
|
if (retryAfter == null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (retryAfter.Delta is { } delta)
|
||||||
|
return Math.Max(1, (int)Math.Ceiling(delta.TotalSeconds));
|
||||||
|
|
||||||
|
if (retryAfter.Date is { } date)
|
||||||
|
{
|
||||||
|
var seconds = (int)Math.Ceiling((date - DateTimeOffset.UtcNow).TotalSeconds);
|
||||||
|
return seconds > 0 ? seconds : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2866,6 +2866,10 @@ public class CsvImportService : ICsvImportService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sub-type is authoritative (matches account create/edit): derive the parent type
|
||||||
|
// from it so a mismatched CSV pair can't post with the wrong debit/credit sign.
|
||||||
|
accountType = AccountClassification.TypeForSubType(accountSubType);
|
||||||
|
|
||||||
DateTime? openingBalanceDate = null;
|
DateTime? openingBalanceDate = null;
|
||||||
if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate)
|
if (!string.IsNullOrWhiteSpace(record.OpeningBalanceDate)
|
||||||
&& DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate))
|
&& DateTime.TryParse(record.OpeningBalanceDate, out var parsedDate))
|
||||||
@@ -3208,6 +3212,33 @@ public class CsvImportService : ICsvImportService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public byte[] GenerateInvoiceItemTemplate()
|
||||||
|
{
|
||||||
|
using var memoryStream = new MemoryStream();
|
||||||
|
using var writer = new StreamWriter(memoryStream);
|
||||||
|
using var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
csv.WriteHeader<InvoiceItemImportDto>();
|
||||||
|
csv.NextRecord();
|
||||||
|
|
||||||
|
csv.WriteRecord(new InvoiceItemImportDto
|
||||||
|
{
|
||||||
|
InvoiceNumber = "INV-2601-0001",
|
||||||
|
Description = "Powder coating - 4 wheels",
|
||||||
|
Quantity = 4,
|
||||||
|
UnitPrice = 75.00m,
|
||||||
|
TotalPrice = 300.00m,
|
||||||
|
ColorName = "Gloss Black",
|
||||||
|
RevenueAccountNumber = "47905",
|
||||||
|
DisplayOrder = 0,
|
||||||
|
Notes = ""
|
||||||
|
});
|
||||||
|
csv.NextRecord();
|
||||||
|
|
||||||
|
writer.Flush();
|
||||||
|
return memoryStream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
public byte[] GeneratePaymentTemplate()
|
public byte[] GeneratePaymentTemplate()
|
||||||
{
|
{
|
||||||
using var memoryStream = new MemoryStream();
|
using var memoryStream = new MemoryStream();
|
||||||
@@ -3223,6 +3254,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
Amount = 250.00m,
|
Amount = 250.00m,
|
||||||
PaymentDate = DateTime.Today,
|
PaymentDate = DateTime.Today,
|
||||||
PaymentMethod = "Check",
|
PaymentMethod = "Check",
|
||||||
|
DepositAccountNumber = "10100",
|
||||||
Reference = "CHK-1234",
|
Reference = "CHK-1234",
|
||||||
Notes = ""
|
Notes = ""
|
||||||
});
|
});
|
||||||
@@ -3232,6 +3264,651 @@ public class CsvImportService : ICsvImportService
|
|||||||
return memoryStream.ToArray();
|
return memoryStream.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports invoice line items from CSV. Each row is matched to its parent invoice by InvoiceNumber;
|
||||||
|
/// the revenue account is resolved (optional) from RevenueAccountNumber against the Chart of Accounts.
|
||||||
|
/// Idempotent — an invoice line with the same description + total + display order is skipped, so the
|
||||||
|
/// import can be safely re-run. Run AFTER invoices have been imported (the parents must exist).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CsvImportResultDto> ImportInvoiceItemsAsync(Stream csvStream, int companyId)
|
||||||
|
{
|
||||||
|
var result = new CsvImportResultDto();
|
||||||
|
var rowNumber = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(csvStream);
|
||||||
|
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
HeaderValidated = null,
|
||||||
|
MissingFieldFound = null
|
||||||
|
});
|
||||||
|
|
||||||
|
var records = csv.GetRecords<InvoiceItemImportDto>().ToList();
|
||||||
|
result.TotalRows = records.Count;
|
||||||
|
|
||||||
|
_logger.LogInformation("Starting import of {Count} invoice line items for company {CompanyId}", records.Count, companyId);
|
||||||
|
|
||||||
|
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.InvoiceItems);
|
||||||
|
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||||
|
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||||
|
var accountByNumber = accounts
|
||||||
|
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||||
|
.GroupBy(a => a.AccountNumber.Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
rowNumber++;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(record.InvoiceNumber))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: InvoiceNumber is required.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!invoiceByNumber.TryGetValue(record.InvoiceNumber.Trim(), out var invoice))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: Invoice '{record.InvoiceNumber}' not found.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||||
|
|
||||||
|
// Idempotency: skip a line that already exists on this invoice.
|
||||||
|
var isDuplicate = invoice.InvoiceItems.Any(it =>
|
||||||
|
string.Equals(it.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& it.TotalPrice == record.TotalPrice
|
||||||
|
&& it.DisplayOrder == record.DisplayOrder);
|
||||||
|
if (isDuplicate)
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on invoice '{record.InvoiceNumber}' — skipped.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the optional revenue account by number so revenue attribution is preserved.
|
||||||
|
int? revenueAccountId = null;
|
||||||
|
var cleanRevenueAccount = StripQuotes(record.RevenueAccountNumber)?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanRevenueAccount))
|
||||||
|
{
|
||||||
|
if (accountByNumber.TryGetValue(cleanRevenueAccount, out var revenueAccount))
|
||||||
|
revenueAccountId = revenueAccount.Id;
|
||||||
|
else
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Revenue account '{cleanRevenueAccount}' not found in Chart of Accounts — line imported without a revenue account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var item = new Core.Entities.InvoiceItem
|
||||||
|
{
|
||||||
|
InvoiceId = invoice.Id,
|
||||||
|
CompanyId = companyId,
|
||||||
|
Description = description,
|
||||||
|
Quantity = record.Quantity,
|
||||||
|
UnitPrice = record.UnitPrice,
|
||||||
|
TotalPrice = record.TotalPrice,
|
||||||
|
ColorName = string.IsNullOrWhiteSpace(record.ColorName) ? null : record.ColorName.Trim(),
|
||||||
|
RevenueAccountId = revenueAccountId,
|
||||||
|
DisplayOrder = record.DisplayOrder,
|
||||||
|
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.InvoiceItems.AddAsync(item);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// Keep the in-memory invoice current so later rows dedup against it correctly.
|
||||||
|
invoice.InvoiceItems.Add(item);
|
||||||
|
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fatal error during invoice item CSV import for company {CompanyId}", companyId);
|
||||||
|
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports vendor bill headers from CSV. Vendor is resolved by name and the AP account by number.
|
||||||
|
/// Dedup by BillNumber. Line items import separately via <see cref="ImportBillLineItemsAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CsvImportResultDto> ImportBillsAsync(Stream csvStream, int companyId)
|
||||||
|
{
|
||||||
|
var result = new CsvImportResultDto();
|
||||||
|
var rowNumber = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(csvStream);
|
||||||
|
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
HeaderValidated = null,
|
||||||
|
MissingFieldFound = null
|
||||||
|
});
|
||||||
|
|
||||||
|
var records = csv.GetRecords<BillImportDto>().ToList();
|
||||||
|
result.TotalRows = records.Count;
|
||||||
|
|
||||||
|
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||||
|
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||||
|
.GroupBy(a => a.AccountNumber.Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var vendors = await _unitOfWork.Vendors.GetAllAsync();
|
||||||
|
var vendorByName = vendors.Where(v => !string.IsNullOrEmpty(v.CompanyName))
|
||||||
|
.GroupBy(v => v.CompanyName.Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var existingBills = await _unitOfWork.Bills.GetAllAsync();
|
||||||
|
var existingBillNumbers = existingBills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||||
|
.Select(b => b.BillNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
rowNumber++;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(billNumber))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: BillNumber is required.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingBillNumbers.Contains(billNumber))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Bill '{billNumber}' already exists — skipped.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanVendor = StripQuotes(record.VendorName)?.Trim() ?? "";
|
||||||
|
if (!vendorByName.TryGetValue(cleanVendor, out var vendor))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: Vendor '{cleanVendor}' not found.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanApAccount = StripQuotes(record.APAccountNumber)?.Trim() ?? "";
|
||||||
|
if (!accountByNumber.TryGetValue(cleanApAccount, out var apAccount))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: AP account '{cleanApAccount}' not found in Chart of Accounts.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse<BillStatus>(record.Status?.Trim(), true, out var status))
|
||||||
|
status = BillStatus.Open;
|
||||||
|
|
||||||
|
var bill = new Core.Entities.Bill
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
BillNumber = billNumber,
|
||||||
|
VendorInvoiceNumber = string.IsNullOrWhiteSpace(record.VendorInvoiceNumber) ? null : record.VendorInvoiceNumber.Trim(),
|
||||||
|
VendorId = vendor.Id,
|
||||||
|
APAccountId = apAccount.Id,
|
||||||
|
BillDate = record.BillDate == default ? DateTime.UtcNow.Date : record.BillDate,
|
||||||
|
DueDate = record.DueDate,
|
||||||
|
Status = status,
|
||||||
|
Terms = string.IsNullOrWhiteSpace(record.Terms) ? null : record.Terms.Trim(),
|
||||||
|
Memo = string.IsNullOrWhiteSpace(record.Memo) ? null : record.Memo.Trim(),
|
||||||
|
SubTotal = record.SubTotal,
|
||||||
|
TaxPercent = record.TaxPercent,
|
||||||
|
TaxAmount = record.TaxAmount,
|
||||||
|
Total = record.Total,
|
||||||
|
AmountPaid = record.AmountPaid
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Bills.AddAsync(bill);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
existingBillNumbers.Add(billNumber);
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fatal error during bill CSV import for company {CompanyId}", companyId);
|
||||||
|
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports vendor bill line items from CSV. Each line is matched to its parent bill by BillNumber;
|
||||||
|
/// the expense/asset account (optional) and job (optional) are resolved by number. Idempotent by
|
||||||
|
/// bill + description + amount + display order. Run after bills have been imported.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CsvImportResultDto> ImportBillLineItemsAsync(Stream csvStream, int companyId)
|
||||||
|
{
|
||||||
|
var result = new CsvImportResultDto();
|
||||||
|
var rowNumber = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(csvStream);
|
||||||
|
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
HeaderValidated = null,
|
||||||
|
MissingFieldFound = null
|
||||||
|
});
|
||||||
|
|
||||||
|
var records = csv.GetRecords<BillLineItemImportDto>().ToList();
|
||||||
|
result.TotalRows = records.Count;
|
||||||
|
|
||||||
|
var bills = await _unitOfWork.Bills.GetAllAsync(false, b => b.LineItems);
|
||||||
|
var billByNumber = bills.Where(b => !string.IsNullOrEmpty(b.BillNumber))
|
||||||
|
.ToDictionary(b => b.BillNumber.Trim(), b => b, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||||
|
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||||
|
.GroupBy(a => a.AccountNumber.Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var jobs = await _unitOfWork.Jobs.GetAllAsync();
|
||||||
|
var jobByNumber = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber))
|
||||||
|
.ToDictionary(j => j.JobNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
rowNumber++;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var billNumber = StripQuotes(record.BillNumber)?.Trim() ?? "";
|
||||||
|
if (!billByNumber.TryGetValue(billNumber, out var bill))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: Bill '{record.BillNumber}' not found.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var description = StripQuotes(record.Description)?.Trim() ?? "";
|
||||||
|
var isDuplicate = bill.LineItems.Any(li =>
|
||||||
|
string.Equals(li.Description, description, StringComparison.OrdinalIgnoreCase)
|
||||||
|
&& li.Amount == record.Amount && li.DisplayOrder == record.DisplayOrder);
|
||||||
|
if (isDuplicate)
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Line '{description}' already exists on bill '{billNumber}' — skipped.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? accountId = null;
|
||||||
|
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanAccount))
|
||||||
|
{
|
||||||
|
if (accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||||
|
accountId = account.Id;
|
||||||
|
else
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Account '{cleanAccount}' not found — line imported without an account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int? jobId = null;
|
||||||
|
var cleanJob = StripQuotes(record.JobNumber)?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanJob) && jobByNumber.TryGetValue(cleanJob, out var job))
|
||||||
|
jobId = job.Id;
|
||||||
|
|
||||||
|
var lineItem = new Core.Entities.BillLineItem
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
BillId = bill.Id,
|
||||||
|
AccountId = accountId,
|
||||||
|
JobId = jobId,
|
||||||
|
Description = description,
|
||||||
|
Quantity = record.Quantity,
|
||||||
|
UnitPrice = record.UnitPrice,
|
||||||
|
Amount = record.Amount,
|
||||||
|
DisplayOrder = record.DisplayOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.BillLineItems.AddAsync(lineItem);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
bill.LineItems.Add(lineItem);
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fatal error during bill line item CSV import for company {CompanyId}", companyId);
|
||||||
|
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports customer deposits from CSV. Customer is resolved by name, the bank account by number,
|
||||||
|
/// and the optional applied invoice by number. Dedup by ReceiptNumber.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CsvImportResultDto> ImportDepositsAsync(Stream csvStream, int companyId)
|
||||||
|
{
|
||||||
|
var result = new CsvImportResultDto();
|
||||||
|
var rowNumber = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(csvStream);
|
||||||
|
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
HeaderValidated = null,
|
||||||
|
MissingFieldFound = null
|
||||||
|
});
|
||||||
|
|
||||||
|
var records = csv.GetRecords<DepositImportDto>().ToList();
|
||||||
|
result.TotalRows = records.Count;
|
||||||
|
|
||||||
|
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||||
|
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||||
|
.GroupBy(a => a.AccountNumber.Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var customers = await _unitOfWork.Customers.GetAllAsync();
|
||||||
|
var customerByName = new Dictionary<string, Core.Entities.Customer>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var c in customers)
|
||||||
|
{
|
||||||
|
var name = !string.IsNullOrWhiteSpace(c.CompanyName)
|
||||||
|
? c.CompanyName.Trim()
|
||||||
|
: $"{c.ContactFirstName} {c.ContactLastName}".Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(name)) customerByName[name] = c;
|
||||||
|
}
|
||||||
|
|
||||||
|
var invoices = await _unitOfWork.Invoices.GetAllAsync();
|
||||||
|
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||||
|
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||||
|
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var existingReceipts = (await _unitOfWork.Deposits.GetAllAsync())
|
||||||
|
.Where(d => !string.IsNullOrEmpty(d.ReceiptNumber))
|
||||||
|
.Select(d => d.ReceiptNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
rowNumber++;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var receiptNumber = StripQuotes(record.ReceiptNumber)?.Trim() ?? "";
|
||||||
|
if (!string.IsNullOrWhiteSpace(receiptNumber) && existingReceipts.Contains(receiptNumber))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Deposit '{receiptNumber}' already exists — skipped.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanCustomer = StripQuotes(record.CustomerName)?.Trim() ?? "";
|
||||||
|
if (!customerByName.TryGetValue(cleanCustomer, out var customer))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: Customer '{cleanCustomer}' not found.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validMethods.TryGetValue(record.PaymentMethod?.Trim() ?? "", out var method))
|
||||||
|
method = PaymentMethod.Cash;
|
||||||
|
|
||||||
|
int? depositAccountId = null;
|
||||||
|
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||||
|
{
|
||||||
|
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||||
|
depositAccountId = depositAccount.Id;
|
||||||
|
else
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found — deposit imported without a bank account.");
|
||||||
|
}
|
||||||
|
|
||||||
|
int? appliedInvoiceId = null;
|
||||||
|
var cleanInvoice = StripQuotes(record.AppliedToInvoiceNumber)?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanInvoice) && invoiceByNumber.TryGetValue(cleanInvoice, out var appliedInvoice))
|
||||||
|
appliedInvoiceId = appliedInvoice.Id;
|
||||||
|
|
||||||
|
var deposit = new Core.Entities.Deposit
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
ReceiptNumber = receiptNumber,
|
||||||
|
CustomerId = customer.Id,
|
||||||
|
Amount = record.Amount,
|
||||||
|
PaymentMethod = method,
|
||||||
|
ReceivedDate = record.ReceivedDate == default ? DateTime.UtcNow.Date : record.ReceivedDate,
|
||||||
|
DepositAccountId = depositAccountId,
|
||||||
|
AppliedToInvoiceId = appliedInvoiceId,
|
||||||
|
AppliedDate = record.AppliedDate,
|
||||||
|
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||||
|
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.Deposits.AddAsync(deposit);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
if (!string.IsNullOrWhiteSpace(receiptNumber)) existingReceipts.Add(receiptNumber);
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fatal error during deposit CSV import for company {CompanyId}", companyId);
|
||||||
|
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports journal entry headers from CSV. Dedup by EntryNumber. The debit/credit lines import
|
||||||
|
/// separately via <see cref="ImportJournalEntryLinesAsync"/>.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CsvImportResultDto> ImportJournalEntriesAsync(Stream csvStream, int companyId)
|
||||||
|
{
|
||||||
|
var result = new CsvImportResultDto();
|
||||||
|
var rowNumber = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(csvStream);
|
||||||
|
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
HeaderValidated = null,
|
||||||
|
MissingFieldFound = null
|
||||||
|
});
|
||||||
|
|
||||||
|
var records = csv.GetRecords<JournalEntryImportDto>().ToList();
|
||||||
|
result.TotalRows = records.Count;
|
||||||
|
|
||||||
|
var existingNumbers = (await _unitOfWork.JournalEntries.GetAllAsync())
|
||||||
|
.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||||
|
.Select(j => j.EntryNumber.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
rowNumber++;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||||
|
if (string.IsNullOrWhiteSpace(entryNumber))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: EntryNumber is required.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (existingNumbers.Contains(entryNumber))
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Journal entry '{entryNumber}' already exists — skipped.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Enum.TryParse<JournalEntryStatus>(record.Status?.Trim(), true, out var status))
|
||||||
|
status = JournalEntryStatus.Draft;
|
||||||
|
|
||||||
|
var entry = new Core.Entities.JournalEntry
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
EntryNumber = entryNumber,
|
||||||
|
EntryDate = record.EntryDate == default ? DateTime.UtcNow.Date : record.EntryDate,
|
||||||
|
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||||
|
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||||
|
Status = status,
|
||||||
|
PostedAt = status == JournalEntryStatus.Posted ? DateTime.UtcNow : null
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.JournalEntries.AddAsync(entry);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
existingNumbers.Add(entryNumber);
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fatal error during journal entry CSV import for company {CompanyId}", companyId);
|
||||||
|
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Imports journal entry lines from CSV. Each line is matched to its parent entry by EntryNumber
|
||||||
|
/// and the account resolved (required) from AccountNumber. Idempotent by entry + account + amounts
|
||||||
|
/// + line order. Run after journal entry headers have been imported.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<CsvImportResultDto> ImportJournalEntryLinesAsync(Stream csvStream, int companyId)
|
||||||
|
{
|
||||||
|
var result = new CsvImportResultDto();
|
||||||
|
var rowNumber = 0;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(csvStream);
|
||||||
|
using var csv = new CsvReader(reader, new CsvConfiguration(CultureInfo.InvariantCulture)
|
||||||
|
{
|
||||||
|
HeaderValidated = null,
|
||||||
|
MissingFieldFound = null
|
||||||
|
});
|
||||||
|
|
||||||
|
var records = csv.GetRecords<JournalEntryLineImportDto>().ToList();
|
||||||
|
result.TotalRows = records.Count;
|
||||||
|
|
||||||
|
var entries = await _unitOfWork.JournalEntries.GetAllAsync(false, j => j.Lines);
|
||||||
|
var entryByNumber = entries.Where(j => !string.IsNullOrEmpty(j.EntryNumber))
|
||||||
|
.ToDictionary(j => j.EntryNumber.Trim(), j => j, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||||
|
var accountByNumber = accounts.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||||
|
.GroupBy(a => a.AccountNumber.Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var record in records)
|
||||||
|
{
|
||||||
|
rowNumber++;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var entryNumber = StripQuotes(record.EntryNumber)?.Trim() ?? "";
|
||||||
|
if (!entryByNumber.TryGetValue(entryNumber, out var entry))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: Journal entry '{record.EntryNumber}' not found.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleanAccount = StripQuotes(record.AccountNumber)?.Trim() ?? "";
|
||||||
|
if (!accountByNumber.TryGetValue(cleanAccount, out var account))
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: Account '{cleanAccount}' not found in Chart of Accounts.");
|
||||||
|
result.ErrorCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isDuplicate = entry.Lines.Any(l =>
|
||||||
|
l.AccountId == account.Id && l.DebitAmount == record.DebitAmount
|
||||||
|
&& l.CreditAmount == record.CreditAmount && l.LineOrder == record.LineOrder);
|
||||||
|
if (isDuplicate)
|
||||||
|
{
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Line for account '{cleanAccount}' already exists on entry '{entryNumber}' — skipped.");
|
||||||
|
result.SkippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var line = new Core.Entities.JournalEntryLine
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
JournalEntryId = entry.Id,
|
||||||
|
AccountId = account.Id,
|
||||||
|
DebitAmount = record.DebitAmount,
|
||||||
|
CreditAmount = record.CreditAmount,
|
||||||
|
Description = string.IsNullOrWhiteSpace(record.Description) ? null : record.Description.Trim(),
|
||||||
|
LineOrder = record.LineOrder
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.JournalEntryLines.AddAsync(line);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
entry.Lines.Add(line);
|
||||||
|
result.SuccessCount++;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
result.Errors.Add($"Row {rowNumber}: {ex.Message}");
|
||||||
|
result.ErrorCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Success = result.ErrorCount == 0 || result.SuccessCount > 0;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Fatal error during journal entry line CSV import for company {CompanyId}", companyId);
|
||||||
|
result.Errors.Add($"Fatal error: {ex.Message}");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
|
public async Task<CsvImportResultDto> ImportPaymentsAsync(Stream csvStream, int companyId)
|
||||||
{
|
{
|
||||||
var result = new CsvImportResultDto();
|
var result = new CsvImportResultDto();
|
||||||
@@ -3256,6 +3933,14 @@ public class CsvImportService : ICsvImportService
|
|||||||
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
var invoiceByNumber = invoices.Where(i => !string.IsNullOrEmpty(i.InvoiceNumber))
|
||||||
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(i => i.InvoiceNumber.Trim(), i => i, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
// Account lookup for resolving the deposit (bank) account by number — optional per row.
|
||||||
|
// Mirrors the expense import so payments round-trip with their bank-account linkage intact.
|
||||||
|
var accounts = await _unitOfWork.Accounts.GetAllAsync();
|
||||||
|
var accountByNumber = accounts
|
||||||
|
.Where(a => !string.IsNullOrEmpty(a.AccountNumber))
|
||||||
|
.GroupBy(a => a.AccountNumber.Trim())
|
||||||
|
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
var validMethods = Enum.GetNames<PaymentMethod>()
|
var validMethods = Enum.GetNames<PaymentMethod>()
|
||||||
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
.ToDictionary(n => n.ToLower(), n => Enum.Parse<PaymentMethod>(n), StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
@@ -3301,6 +3986,18 @@ public class CsvImportService : ICsvImportService
|
|||||||
method = PaymentMethod.Cash;
|
method = PaymentMethod.Cash;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve the optional deposit (bank) account by number so the balance recalc can
|
||||||
|
// post this payment. A blank value is fine; an unknown number warns but still imports.
|
||||||
|
int? depositAccountId = null;
|
||||||
|
var cleanDepositAccount = StripQuotes(record.DepositAccountNumber)?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cleanDepositAccount))
|
||||||
|
{
|
||||||
|
if (accountByNumber.TryGetValue(cleanDepositAccount, out var depositAccount))
|
||||||
|
depositAccountId = depositAccount.Id;
|
||||||
|
else
|
||||||
|
result.Warnings.Add($"Row {rowNumber}: Deposit account '{cleanDepositAccount}' not found in Chart of Accounts — payment imported without a deposit account.");
|
||||||
|
}
|
||||||
|
|
||||||
var payment = new Core.Entities.Payment
|
var payment = new Core.Entities.Payment
|
||||||
{
|
{
|
||||||
InvoiceId = invoice.Id,
|
InvoiceId = invoice.Id,
|
||||||
@@ -3308,6 +4005,7 @@ public class CsvImportService : ICsvImportService
|
|||||||
Amount = record.Amount,
|
Amount = record.Amount,
|
||||||
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
PaymentDate = new DateTime(paymentDate.Year, paymentDate.Month, paymentDate.Day, 0, 0, 0, DateTimeKind.Utc),
|
||||||
PaymentMethod = method,
|
PaymentMethod = method,
|
||||||
|
DepositAccountId = depositAccountId,
|
||||||
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
Reference = string.IsNullOrWhiteSpace(record.Reference) ? null : record.Reference.Trim(),
|
||||||
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
Notes = string.IsNullOrWhiteSpace(record.Notes) ? null : record.Notes.Trim()
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using PowderCoating.Application.DTOs.Accounting;
|
using PowderCoating.Application.DTOs.Accounting;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Accounting;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
@@ -18,10 +19,65 @@ namespace PowderCoating.Infrastructure.Services;
|
|||||||
public class FinancialReportService : IFinancialReportService
|
public class FinancialReportService : IFinancialReportService
|
||||||
{
|
{
|
||||||
private readonly ApplicationDbContext _context;
|
private readonly ApplicationDbContext _context;
|
||||||
|
private readonly ILedgerService _ledger;
|
||||||
|
|
||||||
public FinancialReportService(ApplicationDbContext context)
|
public FinancialReportService(ApplicationDbContext context, ILedgerService ledger)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_ledger = ledger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public async Task<BalanceReconciliationDto> GetBalanceReconciliationAsync(int companyId)
|
||||||
|
{
|
||||||
|
var companyName = await GetCompanyNameAsync(companyId);
|
||||||
|
|
||||||
|
var accounts = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.IsActive && !a.IsDeleted)
|
||||||
|
.OrderBy(a => a.AccountNumber)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
// Epoch start so LedgerService treats OpeningBalance as prior and all activity falls in-window —
|
||||||
|
// identical to how AccountBalanceService.RecalculateAllAsync derives the authoritative balance.
|
||||||
|
var epoch = new DateTime(2000, 1, 1);
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
|
||||||
|
var lines = new List<BalanceReconciliationLine>();
|
||||||
|
decimal arControl = 0m, apControl = 0m;
|
||||||
|
foreach (var a in accounts)
|
||||||
|
{
|
||||||
|
var ledger = await _ledger.GetAccountLedgerAsync(a.Id, epoch, now);
|
||||||
|
var ledgerBalance = ledger?.ClosingBalance ?? 0m;
|
||||||
|
lines.Add(new BalanceReconciliationLine
|
||||||
|
{
|
||||||
|
AccountId = a.Id,
|
||||||
|
AccountNumber = a.AccountNumber,
|
||||||
|
AccountName = a.Name,
|
||||||
|
AccountType = a.AccountType,
|
||||||
|
StoredBalance = a.CurrentBalance,
|
||||||
|
LedgerBalance = ledgerBalance
|
||||||
|
});
|
||||||
|
if (a.AccountSubType == AccountSubType.AccountsReceivable) arControl += ledgerBalance;
|
||||||
|
if (a.AccountSubType == AccountSubType.AccountsPayable) apControl += ledgerBalance;
|
||||||
|
}
|
||||||
|
|
||||||
|
var arSubledger = await _context.Customers
|
||||||
|
.Where(c => c.CompanyId == companyId && !c.IsDeleted)
|
||||||
|
.SumAsync(c => (decimal?)c.CurrentBalance) ?? 0m;
|
||||||
|
var apSubledger = await _context.Vendors
|
||||||
|
.Where(v => v.CompanyId == companyId && !v.IsDeleted)
|
||||||
|
.SumAsync(v => (decimal?)v.CurrentBalance) ?? 0m;
|
||||||
|
|
||||||
|
return new BalanceReconciliationDto
|
||||||
|
{
|
||||||
|
AsOf = now,
|
||||||
|
CompanyName = companyName,
|
||||||
|
AccountLines = lines,
|
||||||
|
ArControlBalance = arControl,
|
||||||
|
ArSubledgerTotal = arSubledger,
|
||||||
|
ApControlBalance = apControl,
|
||||||
|
ApSubledgerTotal = apSubledger
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
@@ -33,7 +89,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
var isCash = accountingMethod == AccountingMethod.Cash;
|
var isCash = accountingMethod == AccountingMethod.Cash;
|
||||||
|
|
||||||
var revenueAccounts = await _context.Accounts
|
var revenueAccounts = await _context.Accounts
|
||||||
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
|
.Where(a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive)
|
||||||
.ToDictionaryAsync(a => a.Id);
|
.ToDictionaryAsync(a => a.Id);
|
||||||
|
|
||||||
var revenueLines = new List<FinancialReportLine>();
|
var revenueLines = new List<FinancialReportLine>();
|
||||||
@@ -42,17 +98,26 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
{
|
{
|
||||||
// Cash basis: total payments received in period (not split by revenue account)
|
// Cash basis: total payments received in period (not split by revenue account)
|
||||||
var cashRevenue = await _context.Payments
|
var cashRevenue = await _context.Payments
|
||||||
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd
|
.Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided)
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
if (cashRevenue > 0)
|
if (cashRevenue > 0)
|
||||||
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue });
|
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Cash Receipts", Amount = cashRevenue });
|
||||||
|
|
||||||
|
// Cash refunds are cash paid back out — they reduce cash-basis revenue.
|
||||||
|
var cashRefunds = await _context.Refunds
|
||||||
|
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RefundMethod != PaymentMethod.StoreCredit
|
||||||
|
&& r.RefundDate >= from && r.RefundDate <= toEnd)
|
||||||
|
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
||||||
|
if (cashRefunds > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine { AccountNumber = "4960", AccountName = "Less: Refunds Paid", Amount = -cashRefunds });
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Accrual basis: revenue = invoice item amounts by invoice date
|
// Accrual basis: revenue = invoice item amounts by invoice date
|
||||||
var accrualRevenue = await _context.InvoiceItems
|
var accrualRevenue = await _context.InvoiceItems
|
||||||
.Where(ii => ii.RevenueAccountId != null
|
.Where(ii => ii.CompanyId == companyId
|
||||||
|
&& ii.RevenueAccountId != null
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||||
@@ -72,7 +137,8 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.OrderBy(l => l.AccountNumber));
|
.OrderBy(l => l.AccountNumber));
|
||||||
|
|
||||||
var unlinkedRevenue = await _context.InvoiceItems
|
var unlinkedRevenue = await _context.InvoiceItems
|
||||||
.Where(ii => ii.RevenueAccountId == null
|
.Where(ii => ii.CompanyId == companyId
|
||||||
|
&& ii.RevenueAccountId == null
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||||
@@ -82,13 +148,19 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
|
// Contra-revenue: discounts granted and credit memos applied reduce gross revenue.
|
||||||
var periodDiscounts = await _context.Invoices
|
var periodDiscounts = await _context.Invoices
|
||||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
&& i.DiscountAmount > 0 && i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
var periodCredits = await _context.CreditMemoApplications
|
// Credit-memo contra-revenue is recognized at issue (DR Sales Discounts). Net for the period =
|
||||||
.Where(a => a.AppliedDate >= from && a.AppliedDate <= toEnd
|
// memos issued in the period minus the unapplied remainder of memos voided in the period.
|
||||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
var periodCmIssued = await _context.CreditMemos
|
||||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
.Where(m => m.CompanyId == companyId && m.IssueDate >= from && m.IssueDate <= toEnd)
|
||||||
|
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||||
|
var periodCmVoided = await _context.CreditMemos
|
||||||
|
.Where(m => m.CompanyId == companyId && m.Status == CreditMemoStatus.Voided
|
||||||
|
&& m.UpdatedAt >= from && m.UpdatedAt <= toEnd)
|
||||||
|
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0m;
|
||||||
|
var periodCredits = periodCmIssued - periodCmVoided;
|
||||||
var totalDeductions = periodDiscounts + periodCredits;
|
var totalDeductions = periodDiscounts + periodCredits;
|
||||||
if (totalDeductions > 0)
|
if (totalDeductions > 0)
|
||||||
revenueLines.Add(new FinancialReportLine
|
revenueLines.Add(new FinancialReportLine
|
||||||
@@ -98,9 +170,26 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
Amount = -totalDeductions
|
Amount = -totalDeductions
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Cash refunds reverse the sale — the revenue portion is contra-revenue (the tax portion
|
||||||
|
// relieves Sales Tax Payable, not revenue). Store-credit refunds are excluded (no GL posting).
|
||||||
|
var periodRefunds = await _context.Refunds
|
||||||
|
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.Invoice != null && r.RefundMethod != PaymentMethod.StoreCredit
|
||||||
|
&& r.RefundDate >= from && r.RefundDate <= toEnd)
|
||||||
|
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total })
|
||||||
|
.ToListAsync();
|
||||||
|
var periodRefundReturns = periodRefunds.Sum(r => RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total).ReturnsPortion);
|
||||||
|
if (periodRefundReturns > 0)
|
||||||
|
revenueLines.Add(new FinancialReportLine
|
||||||
|
{
|
||||||
|
AccountNumber = "4960",
|
||||||
|
AccountName = "Less: Sales Returns",
|
||||||
|
Amount = -periodRefundReturns
|
||||||
|
});
|
||||||
|
|
||||||
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
|
// GC sales are deferred to GC Liability at issuance; revenue is recognized on redemption.
|
||||||
var periodGcReclassified = await _context.InvoiceItems
|
var periodGcReclassified = await _context.InvoiceItems
|
||||||
.Where(ii => ii.IsGiftCertificate
|
.Where(ii => ii.CompanyId == companyId
|
||||||
|
&& ii.IsGiftCertificate
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
&& ii.Invoice.InvoiceDate >= from && ii.Invoice.InvoiceDate <= toEnd)
|
||||||
@@ -115,7 +204,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Voided GCs with remaining balance are breakage income (liability extinguished).
|
// Voided GCs with remaining balance are breakage income (liability extinguished).
|
||||||
var periodGcBreakage = await _context.GiftCertificates
|
var periodGcBreakage = await _context.GiftCertificates
|
||||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
|
&& gc.UpdatedAt >= from && gc.UpdatedAt <= toEnd
|
||||||
&& gc.OriginalAmount > gc.RedeemedAmount)
|
&& gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
@@ -134,7 +223,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
if (isCash)
|
if (isCash)
|
||||||
{
|
{
|
||||||
var cashExpenses = await _context.Expenses
|
var cashExpenses = await _context.Expenses
|
||||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
.Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
|
||||||
.GroupBy(e => e.ExpenseAccountId)
|
.GroupBy(e => e.ExpenseAccountId)
|
||||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -143,7 +232,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Pro-rate paid bill line items by payment fraction (bill total may be partial)
|
// Pro-rate paid bill line items by payment fraction (bill total may be partial)
|
||||||
var paidBillLines = await _context.BillPayments
|
var paidBillLines = await _context.BillPayments
|
||||||
.Where(bp => bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||||
.Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
|
.Include(bp => bp.Bill).ThenInclude(b => b.LineItems)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
foreach (var bp in paidBillLines)
|
foreach (var bp in paidBillLines)
|
||||||
@@ -156,7 +245,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
var accrualExpenses = await _context.Expenses
|
var accrualExpenses = await _context.Expenses
|
||||||
.Where(e => e.Date >= from && e.Date <= toEnd)
|
.Where(e => e.CompanyId == companyId && e.Date >= from && e.Date <= toEnd)
|
||||||
.GroupBy(e => e.ExpenseAccountId)
|
.GroupBy(e => e.ExpenseAccountId)
|
||||||
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
@@ -164,7 +253,8 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
|
||||||
|
|
||||||
var accrualBillLines = await _context.BillLineItems
|
var accrualBillLines = await _context.BillLineItems
|
||||||
.Where(bli => bli.AccountId != null
|
.Where(bli => bli.CompanyId == companyId
|
||||||
|
&& bli.AccountId != null
|
||||||
&& bli.Bill.Status != BillStatus.Draft
|
&& bli.Bill.Status != BillStatus.Draft
|
||||||
&& bli.Bill.Status != BillStatus.Voided
|
&& bli.Bill.Status != BillStatus.Voided
|
||||||
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
|
&& bli.Bill.BillDate >= from && bli.Bill.BillDate <= toEnd)
|
||||||
@@ -173,10 +263,23 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
foreach (var b in accrualBillLines)
|
foreach (var b in accrualBillLines)
|
||||||
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
|
||||||
|
|
||||||
|
// Inventory consumed on jobs posts DR COGS / CR Inventory — recognise the COGS in the period.
|
||||||
|
// (Cash basis recognises inventory cost when purchased, so this applies to accrual only.)
|
||||||
|
var consumptionCogs = await _context.InventoryTransactions
|
||||||
|
.Where(t => t.CompanyId == companyId
|
||||||
|
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||||
|
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||||
|
&& t.TransactionDate >= from && t.TransactionDate <= toEnd)
|
||||||
|
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
|
||||||
|
.Select(g => new { AccountId = g.Key, Amount = g.Sum(t => t.TotalCost) })
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var c in consumptionCogs)
|
||||||
|
expenseAmounts[c.AccountId] = expenseAmounts.GetValueOrDefault(c.AccountId) + c.Amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
var expAccounts = await _context.Accounts
|
var expAccounts = await _context.Accounts
|
||||||
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
.Where(a => a.CompanyId == companyId && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
|
||||||
.ToDictionaryAsync(a => a.Id);
|
.ToDictionaryAsync(a => a.Id);
|
||||||
|
|
||||||
var cogsLines = new List<FinancialReportLine>();
|
var cogsLines = new List<FinancialReportLine>();
|
||||||
@@ -216,46 +319,45 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
|
// Pre-compute balance contributions per account (batch GROUP BY queries avoid N+1)
|
||||||
|
|
||||||
var depositsByAcct = await _context.Payments
|
var depositsByAcct = await _context.Payments
|
||||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.GroupBy(p => p.DepositAccountId!.Value)
|
.GroupBy(p => p.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var expFromByAcct = await _context.Expenses
|
var expFromByAcct = await _context.Expenses
|
||||||
.Where(e => e.Date <= asOfEnd)
|
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||||
.GroupBy(e => e.PaymentAccountId)
|
.GroupBy(e => e.PaymentAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var bpFromByAcct = await _context.BillPayments
|
var bpFromByAcct = await _context.BillPayments
|
||||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||||
.GroupBy(bp => bp.BankAccountId)
|
.GroupBy(bp => bp.BankAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var billsByApAcct = await _context.Bills
|
var billsByApAcct = await _context.Bills
|
||||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
.Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||||
.GroupBy(b => b.APAccountId)
|
.GroupBy(b => b.APAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var bpByApAcct = await _context.BillPayments
|
var bpByApAcct = await _context.BillPayments
|
||||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||||
.GroupBy(bp => bp.Bill.APAccountId)
|
.GroupBy(bp => bp.Bill.APAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
|
// AP: vendor credit applications reduce AP (DR side) when matched against specific bills.
|
||||||
var vcByApAcctBs = await _context.VendorCreditApplications
|
var vcByApAcctBs = await _context.VendorCreditApplications
|
||||||
.Where(vca => vca.AppliedDate <= asOfEnd)
|
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
|
||||||
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(vca => vca.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var taxByAcct = await _context.Invoices
|
var taxByAcct = await _context.Invoices
|
||||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
.Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
&& i.InvoiceDate <= asOfEnd)
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||||
@@ -263,32 +365,67 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
var arDebits = await _context.Invoices
|
var arDebits = await _context.Invoices
|
||||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||||
var arCredits = await _context.Payments
|
var arCredits = await _context.Payments
|
||||||
.Where(p => p.PaymentDate <= asOfEnd
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
// Credit memo applications reduce open AR (CR AR when a credit is applied to an invoice).
|
||||||
arCredits += await _context.CreditMemoApplications
|
var cmAppliedBs = await _context.CreditMemoApplications
|
||||||
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
// Refunds reverse collected payments — they re-open AR so reduce net AR credits.
|
arCredits += cmAppliedBs;
|
||||||
arCredits -= await _context.Refunds
|
// Gift-certificate redemptions also credit AR (ApplyGiftCertificate posts DR 2500 / CR AR).
|
||||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
// Mirror the posting here so AR is not overstated and the entry's two sides stay balanced.
|
||||||
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
var gcRedeemedBs = await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
|
||||||
|
&& r.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||||
|
arCredits += gcRedeemedBs;
|
||||||
|
|
||||||
|
// Customer Credits (2350): a credit memo books DR Sales Discounts / CR Customer Credits on issue,
|
||||||
|
// then DR Customer Credits / CR AR on apply. Contra-revenue (retained earnings) = issued amount
|
||||||
|
// (active in full + applied portion of voided); the 2350 liability = unapplied balance on active memos.
|
||||||
|
var cmIssuedNonVoidedBs = await _context.CreditMemos
|
||||||
|
.Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||||
|
var cmAppliedNonVoidedBs = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& a.CreditMemo.Status != CreditMemoStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
var cmContraRevenueBs = cmIssuedNonVoidedBs + (cmAppliedBs - cmAppliedNonVoidedBs);
|
||||||
|
var customerCreditsAcctIdBs = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
// Cash refunds reverse the sale: revenue portion reduces retained earnings (Sales Returns),
|
||||||
|
// tax portion relieves Sales Tax Payable, cash leaves the bank (refundsByAcctBs). AR is untouched.
|
||||||
|
// Store-credit refunds post via CreditMemo, not the GL, so are excluded.
|
||||||
|
var saleReversingRefundsBs = await _context.Refunds
|
||||||
|
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
|
||||||
|
&& r.RefundMethod != PaymentMethod.StoreCredit)
|
||||||
|
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
|
||||||
|
.ToListAsync();
|
||||||
|
decimal refundReturnsTotalBs = 0m;
|
||||||
|
var refundTaxByAcctBs = new Dictionary<int, decimal>();
|
||||||
|
foreach (var r in saleReversingRefundsBs)
|
||||||
|
{
|
||||||
|
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
|
||||||
|
refundReturnsTotalBs += returnsPortion;
|
||||||
|
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
|
||||||
|
refundTaxByAcctBs[r.SalesTaxAccountId.Value] = refundTaxByAcctBs.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
|
||||||
|
}
|
||||||
|
|
||||||
// Refunds by bank account: money that left the account (CR to checking/bank).
|
// Refunds by bank account: money that left the account (CR to checking/bank).
|
||||||
var refundsByAcctBs = await _context.Refunds
|
var refundsByAcctBs = await _context.Refunds
|
||||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
.GroupBy(r => r.DepositAccountId!.Value)
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(r => r.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
|
|
||||||
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
var depositsByAcctDepBs = await _context.Deposits
|
var depositsByAcctDepBs = await _context.Deposits
|
||||||
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
.GroupBy(d => d.DepositAccountId!.Value)
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
|
.Select(g => new { Id = g.Key, Amount = g.Sum(d => d.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
.ToDictionaryAsync(g => g.Id, g => g.Amount);
|
||||||
@@ -299,11 +436,11 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
|
var custDepositsCreditsBs = custDepositsAcctIdBs.HasValue
|
||||||
? (await _context.Deposits
|
? (await _context.Deposits
|
||||||
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
|
var custDepositsDebitsBs = custDepositsAcctIdBs.HasValue
|
||||||
? (await _context.Deposits
|
? (await _context.Deposits
|
||||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
@@ -312,14 +449,14 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
|
var gcLiabilityCreditsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
? (await _context.GiftCertificates
|
? (await _context.GiftCertificates
|
||||||
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
|
var gcLiabilityDebitsBs = gcLiabilityAcctIdBs.HasValue
|
||||||
? ((await _context.GiftCertificateRedemptions
|
? ((await _context.GiftCertificateRedemptions
|
||||||
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
+ (await _context.GiftCertificates
|
+ (await _context.GiftCertificates
|
||||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
@@ -328,23 +465,21 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
|
// plus (5) the net effect of any posted journal entries on revenue/expense/COGS accounts
|
||||||
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
|
// (accruals, depreciation, year-end closes, and other adjustments not in the tables above).
|
||||||
var lifetimeRevenue = await _context.InvoiceItems
|
var lifetimeRevenue = await _context.InvoiceItems
|
||||||
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
.Where(ii => ii.CompanyId == companyId && ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
|
||||||
var lifetimeDiscounts = isCash ? 0m
|
var lifetimeDiscounts = isCash ? 0m
|
||||||
: (await _context.Invoices
|
: (await _context.Invoices
|
||||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
|
&& i.DiscountAmount > 0 && i.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m);
|
||||||
// Credit memos applied to invoices reduce net revenue (contra-revenue, same as discounts).
|
// Credit memos are contra-revenue recognized at issue (DR Sales Discounts). Net revenue is
|
||||||
var lifetimeCreditMemos = isCash ? 0m
|
// reduced by the issued amount (active memos in full + applied portion of voided memos).
|
||||||
: (await _context.CreditMemoApplications
|
var lifetimeCreditMemos = isCash ? 0m : cmContraRevenueBs;
|
||||||
.Where(a => a.AppliedDate <= asOfEnd && a.Invoice.Status != InvoiceStatus.Voided)
|
|
||||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m);
|
|
||||||
var lifetimeDirectExp = await _context.Expenses
|
var lifetimeDirectExp = await _context.Expenses
|
||||||
.Where(e => e.Date <= asOfEnd)
|
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||||
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
.SumAsync(e => (decimal?)e.Amount) ?? 0;
|
||||||
var lifetimeBillCosts = await _context.BillLineItems
|
var lifetimeBillCosts = await _context.BillLineItems
|
||||||
.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
.Where(bli => bli.CompanyId == companyId && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd)
|
||||||
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
.SumAsync(bli => (decimal?)bli.Amount) ?? 0;
|
||||||
|
|
||||||
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
|
// JE net effect on revenue accounts (positive = additional revenue recognised via manual JE)
|
||||||
@@ -376,20 +511,21 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
|
// GC items sold via invoices are reclassified to GC Liability and not yet earned income.
|
||||||
var lifetimeGcReclassified = await _context.InvoiceItems
|
var lifetimeGcReclassified = await _context.InvoiceItems
|
||||||
.Where(ii => ii.IsGiftCertificate
|
.Where(ii => ii.CompanyId == companyId && ii.IsGiftCertificate
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0m;
|
||||||
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
|
// Voided GCs with remaining balance become breakage income (the liability is extinguished).
|
||||||
var lifetimeGcBreakage = await _context.GiftCertificates
|
var lifetimeGcBreakage = await _context.GiftCertificates
|
||||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m;
|
||||||
|
|
||||||
var retainedEarnings = lifetimeRevenue + jeRevNet
|
var retainedEarnings = lifetimeRevenue + jeRevNet
|
||||||
- lifetimeDiscounts
|
- lifetimeDiscounts
|
||||||
- lifetimeCreditMemos
|
- lifetimeCreditMemos
|
||||||
|
- refundReturnsTotalBs // revenue portion of cash refunds (reversed sales)
|
||||||
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
|
- lifetimeGcReclassified // deferred to GC Liability, not earned yet
|
||||||
+ lifetimeGcBreakage // breakage income when GC voided with balance
|
+ lifetimeGcBreakage // breakage income when GC voided with balance
|
||||||
- lifetimeDirectExp
|
- lifetimeDirectExp
|
||||||
@@ -397,7 +533,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
- jeExpNet;
|
- jeExpNet;
|
||||||
|
|
||||||
var accounts = await _context.Accounts
|
var accounts = await _context.Accounts
|
||||||
.Where(a => a.IsActive)
|
.Where(a => a.CompanyId == companyId && a.IsActive)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
@@ -425,11 +561,17 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
credits += taxByAcct.GetValueOrDefault(a.Id);
|
credits += taxByAcct.GetValueOrDefault(a.Id);
|
||||||
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
credits += refundsByAcctBs.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
|
debits += depositsByAcctDepBs.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
debits += refundTaxByAcctBs.GetValueOrDefault(a.Id); // refund tax portion relieves the tax liability
|
||||||
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
|
if (gcLiabilityAcctIdBs.HasValue && a.Id == gcLiabilityAcctIdBs.Value)
|
||||||
{
|
{
|
||||||
credits += gcLiabilityCreditsBs; // GC issued → CR liability
|
credits += gcLiabilityCreditsBs; // GC issued → CR liability
|
||||||
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
|
debits += gcLiabilityDebitsBs; // redeemed/voided → DR liability
|
||||||
}
|
}
|
||||||
|
if (customerCreditsAcctIdBs.HasValue && a.Id == customerCreditsAcctIdBs.Value)
|
||||||
|
{
|
||||||
|
credits += cmIssuedNonVoidedBs; // credit memos issued → CR liability
|
||||||
|
debits += cmAppliedNonVoidedBs; // applied → DR liability
|
||||||
|
}
|
||||||
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
|
if (custDepositsAcctIdBs.HasValue && a.Id == custDepositsAcctIdBs.Value)
|
||||||
{
|
{
|
||||||
credits += custDepositsCreditsBs; // deposits taken → CR liability
|
credits += custDepositsCreditsBs; // deposits taken → CR liability
|
||||||
@@ -499,7 +641,8 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
var openInvoices = await _context.Invoices
|
var openInvoices = await _context.Invoices
|
||||||
.Include(i => i.Customer)
|
.Include(i => i.Customer)
|
||||||
.Where(i => i.Status != InvoiceStatus.Draft
|
.Where(i => i.CompanyId == companyId
|
||||||
|
&& i.Status != InvoiceStatus.Draft
|
||||||
&& i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Voided
|
||||||
&& i.Status != InvoiceStatus.Paid
|
&& i.Status != InvoiceStatus.Paid
|
||||||
&& i.InvoiceDate <= asOfEnd
|
&& i.InvoiceDate <= asOfEnd
|
||||||
@@ -579,14 +722,15 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
var invoices = await _context.Invoices
|
var invoices = await _context.Invoices
|
||||||
.Include(i => i.Customer)
|
.Include(i => i.Customer)
|
||||||
.Include(i => i.Payments)
|
.Include(i => i.Payments)
|
||||||
.Where(i => i.Status != InvoiceStatus.Draft
|
.Where(i => i.CompanyId == companyId
|
||||||
|
&& i.Status != InvoiceStatus.Draft
|
||||||
&& i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Voided
|
||||||
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||||
.OrderBy(i => i.InvoiceDate)
|
.OrderBy(i => i.InvoiceDate)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
var collectedInPeriod = await _context.Payments
|
var collectedInPeriod = await _context.Payments
|
||||||
.Where(p => p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
.Where(p => p.CompanyId == companyId && p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
|
|
||||||
var byCustomer = invoices
|
var byCustomer = invoices
|
||||||
@@ -851,9 +995,8 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Bank/cash: customer payments deposited here (DR)
|
// Bank/cash: customer payments deposited here (DR)
|
||||||
var depositsByAcct = await _context.Payments
|
var depositsByAcct = await _context.Payments
|
||||||
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd && p.DepositAccountId != null
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.GroupBy(p => p.DepositAccountId!.Value)
|
.GroupBy(p => p.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(p => p.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
@@ -861,42 +1004,42 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
|
// AP: vendor credit applications reduce AP (DR) — credits are applied when a vendor
|
||||||
// issues a credit note and it is matched against a specific bill.
|
// issues a credit note and it is matched against a specific bill.
|
||||||
var vcByApAcct = await _context.VendorCreditApplications
|
var vcByApAcct = await _context.VendorCreditApplications
|
||||||
.Where(vca => vca.AppliedDate <= asOfEnd)
|
.Where(vca => vca.CompanyId == companyId && vca.AppliedDate <= asOfEnd)
|
||||||
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
.GroupBy(vca => vca.VendorCredit.APAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(vca => vca.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
// Bank/cash: expenses paid from here (CR)
|
// Bank/cash: expenses paid from here (CR)
|
||||||
var expFromByAcct = await _context.Expenses
|
var expFromByAcct = await _context.Expenses
|
||||||
.Where(e => e.Date <= asOfEnd)
|
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||||
.GroupBy(e => e.PaymentAccountId)
|
.GroupBy(e => e.PaymentAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
// Bank/cash: bill payments made from here (CR)
|
// Bank/cash: bill payments made from here (CR)
|
||||||
var bpFromByAcct = await _context.BillPayments
|
var bpFromByAcct = await _context.BillPayments
|
||||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||||
.GroupBy(bp => bp.BankAccountId)
|
.GroupBy(bp => bp.BankAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
// AP: bills increase AP (CR)
|
// AP: bills increase AP (CR)
|
||||||
var billsByApAcct = await _context.Bills
|
var billsByApAcct = await _context.Bills
|
||||||
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
.Where(b => b.CompanyId == companyId && b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
|
||||||
.GroupBy(b => b.APAccountId)
|
.GroupBy(b => b.APAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(b => b.Total) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
// AP: bill payments reduce AP (DR)
|
// AP: bill payments reduce AP (DR)
|
||||||
var bpByApAcct = await _context.BillPayments
|
var bpByApAcct = await _context.BillPayments
|
||||||
.Where(bp => bp.PaymentDate <= asOfEnd)
|
.Where(bp => bp.CompanyId == companyId && bp.PaymentDate <= asOfEnd)
|
||||||
.GroupBy(bp => bp.Bill.APAccountId)
|
.GroupBy(bp => bp.Bill.APAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bp => bp.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
// Tax liability: sales tax collected (CR)
|
// Tax liability: sales tax collected (CR)
|
||||||
var taxByAcct = await _context.Invoices
|
var taxByAcct = await _context.Invoices
|
||||||
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
|
.Where(i => i.CompanyId == companyId && i.SalesTaxAccountId != null && i.TaxAmount > 0
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
&& i.InvoiceDate <= asOfEnd)
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
.GroupBy(i => i.SalesTaxAccountId!.Value)
|
||||||
@@ -905,7 +1048,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Revenue accounts: invoice line items (CR)
|
// Revenue accounts: invoice line items (CR)
|
||||||
var revenueByAcct = await _context.InvoiceItems
|
var revenueByAcct = await _context.InvoiceItems
|
||||||
.Where(ii => ii.RevenueAccountId != null
|
.Where(ii => ii.CompanyId == companyId && ii.RevenueAccountId != null
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Draft
|
&& ii.Invoice.Status != InvoiceStatus.Draft
|
||||||
&& ii.Invoice.Status != InvoiceStatus.Voided
|
&& ii.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
&& ii.Invoice.InvoiceDate <= asOfEnd)
|
||||||
@@ -915,14 +1058,14 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Expense accounts: direct expenses (DR)
|
// Expense accounts: direct expenses (DR)
|
||||||
var expenseByAcct = await _context.Expenses
|
var expenseByAcct = await _context.Expenses
|
||||||
.Where(e => e.Date <= asOfEnd)
|
.Where(e => e.CompanyId == companyId && e.Date <= asOfEnd)
|
||||||
.GroupBy(e => e.ExpenseAccountId)
|
.GroupBy(e => e.ExpenseAccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(e => e.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
// Expense/COGS accounts: vendor bill line items (DR)
|
// Expense/COGS accounts: vendor bill line items (DR)
|
||||||
var billLinesByAcct = await _context.BillLineItems
|
var billLinesByAcct = await _context.BillLineItems
|
||||||
.Where(bli => bli.AccountId != null
|
.Where(bli => bli.CompanyId == companyId && bli.AccountId != null
|
||||||
&& bli.Bill.Status != BillStatus.Draft
|
&& bli.Bill.Status != BillStatus.Draft
|
||||||
&& bli.Bill.Status != BillStatus.Voided
|
&& bli.Bill.Status != BillStatus.Voided
|
||||||
&& bli.Bill.BillDate <= asOfEnd)
|
&& bli.Bill.BillDate <= asOfEnd)
|
||||||
@@ -930,6 +1073,25 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(bli => bli.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
|
// Inventory consumption: COGS account (DR) and Inventory asset account (CR) for JobUsage/Waste
|
||||||
|
// transactions on items with both accounts mapped — mirrors the DR COGS / CR Inventory posting.
|
||||||
|
var cogsConsumptionByAcct = await _context.InventoryTransactions
|
||||||
|
.Where(t => t.CompanyId == companyId
|
||||||
|
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||||
|
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||||
|
&& t.TransactionDate <= asOfEnd)
|
||||||
|
.GroupBy(t => t.InventoryItem.CogsAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
var invConsumptionByAcct = await _context.InventoryTransactions
|
||||||
|
.Where(t => t.CompanyId == companyId
|
||||||
|
&& (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||||
|
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||||
|
&& t.TransactionDate <= asOfEnd)
|
||||||
|
.GroupBy(t => t.InventoryItem.InventoryAccountId!.Value)
|
||||||
|
.Select(g => new { Id = g.Key, Amt = g.Sum(t => t.TotalCost) })
|
||||||
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
|
// Sales Discounts contra-revenue account: invoice discounts and credit memo applications (DR).
|
||||||
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
|
// Both reduce net revenue and are attributed to account 4950 as contra-revenue debits.
|
||||||
// Credit memo applications are also added to AR credits below so the double-entry balances.
|
// Credit memo applications are also added to AR credits below so the double-entry balances.
|
||||||
@@ -944,33 +1106,50 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.FirstOrDefaultAsync();
|
.FirstOrDefaultAsync();
|
||||||
|
|
||||||
var cmApplied = await _context.CreditMemoApplications
|
var cmApplied = await _context.CreditMemoApplications
|
||||||
.Where(a => a.AppliedDate <= asOfEnd
|
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd
|
||||||
&& a.Invoice.Status != InvoiceStatus.Voided)
|
&& a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
|
||||||
|
// Customer Credits (2350) model: a credit memo books DR Sales Discounts / CR Customer Credits on
|
||||||
|
// issue, then DR Customer Credits / CR AR on apply. So the 4950 contra-revenue is the *issued*
|
||||||
|
// amount (active memos in full + the applied portion of voided memos), and the 2350 liability is
|
||||||
|
// the unapplied balance on active memos. AR is still credited by applications (cmApplied).
|
||||||
|
var cmIssuedNonVoided = await _context.CreditMemos
|
||||||
|
.Where(m => m.CompanyId == companyId && m.Status != CreditMemoStatus.Voided && m.IssueDate <= asOfEnd)
|
||||||
|
.SumAsync(m => (decimal?)m.Amount) ?? 0m;
|
||||||
|
var cmAppliedNonVoided = await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AppliedDate <= asOfEnd
|
||||||
|
&& a.Invoice.Status != InvoiceStatus.Voided
|
||||||
|
&& a.CreditMemo.Status != CreditMemoStatus.Voided)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0m;
|
||||||
|
var cmContraRevenue = cmIssuedNonVoided + (cmApplied - cmAppliedNonVoided); // DR 4950
|
||||||
|
var customerCreditsAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "2350" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
|
||||||
var discountsByAcct = new Dictionary<int, decimal>();
|
var discountsByAcct = new Dictionary<int, decimal>();
|
||||||
if (discountAcctId.HasValue)
|
if (discountAcctId.HasValue)
|
||||||
{
|
{
|
||||||
var totalDiscounts = await _context.Invoices
|
var totalDiscounts = await _context.Invoices
|
||||||
.Where(i => i.DiscountAmount > 0
|
.Where(i => i.CompanyId == companyId && i.DiscountAmount > 0
|
||||||
&& i.Status != InvoiceStatus.Draft
|
&& i.Status != InvoiceStatus.Draft
|
||||||
&& i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Voided
|
||||||
&& i.InvoiceDate <= asOfEnd)
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0m;
|
||||||
if (totalDiscounts + cmApplied > 0)
|
if (totalDiscounts + cmContraRevenue > 0)
|
||||||
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmApplied;
|
discountsByAcct[discountAcctId.Value] = totalDiscounts + cmContraRevenue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// JE lines: posted entries debit/credit all account types
|
// JE lines: posted entries debit/credit all account types
|
||||||
var jeDebitsByAcct = await _context.JournalEntryLines
|
var jeDebitsByAcct = await _context.JournalEntryLines
|
||||||
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
.Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
.GroupBy(l => l.AccountId)
|
.GroupBy(l => l.AccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.DebitAmount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
|
|
||||||
var jeCreditsByAcct = await _context.JournalEntryLines
|
var jeCreditsByAcct = await _context.JournalEntryLines
|
||||||
.Where(l => l.JournalEntry.Status == JournalEntryStatus.Posted
|
.Where(l => l.CompanyId == companyId && l.JournalEntry.Status == JournalEntryStatus.Posted
|
||||||
&& l.JournalEntry.EntryDate <= asOfEnd)
|
&& l.JournalEntry.EntryDate <= asOfEnd)
|
||||||
.GroupBy(l => l.AccountId)
|
.GroupBy(l => l.AccountId)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(l => l.CreditAmount) })
|
||||||
@@ -980,25 +1159,48 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
// Credits include both cash payments and credit memo applications (which reduce open AR
|
// Credits include both cash payments and credit memo applications (which reduce open AR
|
||||||
// when a customer credit is applied against a specific invoice).
|
// when a customer credit is applied against a specific invoice).
|
||||||
var arTotalDebits = await _context.Invoices
|
var arTotalDebits = await _context.Invoices
|
||||||
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
.Where(i => i.CompanyId == companyId && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
&& i.InvoiceDate <= asOfEnd)
|
&& i.InvoiceDate <= asOfEnd)
|
||||||
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
.SumAsync(i => (decimal?)i.Total) ?? 0m;
|
||||||
var arTotalCredits = await _context.Payments
|
var arTotalCredits = await _context.Payments
|
||||||
.Where(p => p.PaymentDate <= asOfEnd
|
.Where(p => p.CompanyId == companyId && p.PaymentDate <= asOfEnd
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided)
|
||||||
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
|
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0m;
|
||||||
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
arTotalCredits += cmApplied; // credit memo applications reduce AR balance
|
||||||
|
// Gift-certificate redemptions credit AR too (DR 2500 / CR AR). Without this the redemption's
|
||||||
|
// 2500 debit is recomputed but its AR credit is not, leaving the trial balance out of balance.
|
||||||
|
var gcRedeemedTb = await _context.GiftCertificateRedemptions
|
||||||
|
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd
|
||||||
|
&& r.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m;
|
||||||
|
arTotalCredits += gcRedeemedTb;
|
||||||
|
|
||||||
// Refunds reverse collected payments — reduce net AR credits (re-opens the receivable).
|
// Cash refunds reverse the sale: revenue portion → DR Sales Returns (4960), tax portion →
|
||||||
var refundTotal = await _context.Refunds
|
// DR Sales Tax Payable (relieves the liability), cash → CR bank (refundsByAcct below). They no
|
||||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted)
|
// longer touch AR. Store-credit refunds post via CreditMemo, not the GL, so are excluded.
|
||||||
.SumAsync(r => (decimal?)r.Amount) ?? 0m;
|
var saleReversingRefunds = await _context.Refunds
|
||||||
arTotalCredits -= refundTotal;
|
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.Invoice != null
|
||||||
|
&& r.RefundMethod != PaymentMethod.StoreCredit)
|
||||||
|
.Select(r => new { r.Amount, r.Invoice!.TaxAmount, r.Invoice.Total, r.Invoice.SalesTaxAccountId })
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
decimal refundReturnsTotal = 0m;
|
||||||
|
var refundTaxByAcct = new Dictionary<int, decimal>();
|
||||||
|
foreach (var r in saleReversingRefunds)
|
||||||
|
{
|
||||||
|
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.TaxAmount, r.Total);
|
||||||
|
refundReturnsTotal += returnsPortion;
|
||||||
|
if (taxPortion != 0m && r.SalesTaxAccountId.HasValue)
|
||||||
|
refundTaxByAcct[r.SalesTaxAccountId.Value] = refundTaxByAcct.GetValueOrDefault(r.SalesTaxAccountId.Value) + taxPortion;
|
||||||
|
}
|
||||||
|
|
||||||
|
var salesReturnsAcctId = await _context.Accounts
|
||||||
|
.Where(a => a.CompanyId == companyId && a.AccountNumber == "4960" && a.IsActive && !a.IsDeleted)
|
||||||
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
|
|
||||||
// Refunds by bank account: money leaving the account (CR to checking/bank).
|
// Refunds by bank account: money leaving the account (CR to checking/bank).
|
||||||
var refundsByAcct = await _context.Refunds
|
var refundsByAcct = await _context.Refunds
|
||||||
.Where(r => r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
.Where(r => r.CompanyId == companyId && r.RefundDate <= asOfEnd && !r.IsDeleted && r.DepositAccountId != null)
|
||||||
.GroupBy(r => r.DepositAccountId!.Value)
|
.GroupBy(r => r.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(r => r.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
@@ -1006,7 +1208,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
// Deposits by bank account: cash received at deposit recording time (DR bank).
|
||||||
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
|
// Deposit-sourced Payments have DepositAccountId = null, so there is no double-count with depositsByAcct.
|
||||||
var depositsByAcctDep = await _context.Deposits
|
var depositsByAcctDep = await _context.Deposits
|
||||||
.Where(d => !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.DepositAccountId != null && d.ReceivedDate <= asOfEnd)
|
||||||
.GroupBy(d => d.DepositAccountId!.Value)
|
.GroupBy(d => d.DepositAccountId!.Value)
|
||||||
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
|
.Select(g => new { Id = g.Key, Amt = g.Sum(d => d.Amount) })
|
||||||
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
.ToDictionaryAsync(g => g.Id, g => g.Amt);
|
||||||
@@ -1017,11 +1219,11 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
var custDepositsCredits = custDepositsAcctId.HasValue
|
var custDepositsCredits = custDepositsAcctId.HasValue
|
||||||
? (await _context.Deposits
|
? (await _context.Deposits
|
||||||
.Where(d => !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.ReceivedDate <= asOfEnd)
|
||||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
var custDepositsDebits = custDepositsAcctId.HasValue
|
var custDepositsDebits = custDepositsAcctId.HasValue
|
||||||
? (await _context.Deposits
|
? (await _context.Deposits
|
||||||
.Where(d => !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
.Where(d => d.CompanyId == companyId && !d.IsDeleted && d.AppliedToInvoiceId != null && d.AppliedDate <= asOfEnd)
|
||||||
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
.SumAsync(d => (decimal?)d.Amount) ?? 0m) : 0m;
|
||||||
|
|
||||||
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
// Gift Certificate Liability (2500): balance driven by GC issuances, redemptions, and voids.
|
||||||
@@ -1030,14 +1232,14 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
.Select(a => (int?)a.Id).FirstOrDefaultAsync();
|
||||||
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
|
var gcLiabilityCredits = gcLiabilityAcctId.HasValue
|
||||||
? (await _context.GiftCertificates
|
? (await _context.GiftCertificates
|
||||||
.Where(gc => !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.IssueDate <= asOfEnd)
|
||||||
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
.SumAsync(gc => (decimal?)gc.OriginalAmount) ?? 0m) : 0m;
|
||||||
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
|
var gcLiabilityDebits = gcLiabilityAcctId.HasValue
|
||||||
? ((await _context.GiftCertificateRedemptions
|
? ((await _context.GiftCertificateRedemptions
|
||||||
.Where(r => !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
.Where(r => r.CompanyId == companyId && !r.IsDeleted && r.RedeemedDate <= asOfEnd)
|
||||||
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0m)
|
||||||
+ (await _context.GiftCertificates
|
+ (await _context.GiftCertificates
|
||||||
.Where(gc => !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
.Where(gc => gc.CompanyId == companyId && !gc.IsDeleted && gc.Status == GiftCertificateStatus.Voided
|
||||||
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
&& gc.UpdatedAt <= asOfEnd && gc.OriginalAmount > gc.RedeemedAmount)
|
||||||
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
.SumAsync(gc => (decimal?)(gc.OriginalAmount - gc.RedeemedAmount)) ?? 0m)) : 0m;
|
||||||
|
|
||||||
@@ -1077,8 +1279,13 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
debits += expenseByAcct.GetValueOrDefault(a.Id);
|
debits += expenseByAcct.GetValueOrDefault(a.Id);
|
||||||
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
debits += billLinesByAcct.GetValueOrDefault(a.Id);
|
||||||
debits += discountsByAcct.GetValueOrDefault(a.Id);
|
debits += discountsByAcct.GetValueOrDefault(a.Id);
|
||||||
|
debits += cogsConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → DR COGS
|
||||||
|
credits += invConsumptionByAcct.GetValueOrDefault(a.Id); // inventory consumption → CR Inventory
|
||||||
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
credits += refundsByAcct.GetValueOrDefault(a.Id); // refunds reduce bank balance
|
||||||
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
debits += depositsByAcctDep.GetValueOrDefault(a.Id); // deposits increase bank balance
|
||||||
|
if (salesReturnsAcctId.HasValue && a.Id == salesReturnsAcctId.Value)
|
||||||
|
debits += refundReturnsTotal; // revenue portion of cash refunds
|
||||||
|
debits += refundTaxByAcct.GetValueOrDefault(a.Id); // tax portion relieves the tax liability
|
||||||
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
|
if (gcLiabilityAcctId.HasValue && a.Id == gcLiabilityAcctId.Value)
|
||||||
{
|
{
|
||||||
credits += gcLiabilityCredits; // GC issued → CR liability
|
credits += gcLiabilityCredits; // GC issued → CR liability
|
||||||
@@ -1089,6 +1296,11 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
credits += custDepositsCredits; // deposits taken → CR liability
|
credits += custDepositsCredits; // deposits taken → CR liability
|
||||||
debits += custDepositsDebits; // deposits applied → DR liability
|
debits += custDepositsDebits; // deposits applied → DR liability
|
||||||
}
|
}
|
||||||
|
if (customerCreditsAcctId.HasValue && a.Id == customerCreditsAcctId.Value)
|
||||||
|
{
|
||||||
|
credits += cmIssuedNonVoided; // credit memos issued → CR liability
|
||||||
|
debits += cmAppliedNonVoided; // applied → DR liability
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
|
// Manual JEs apply to all account types (including AR/AP for unusual adjustments)
|
||||||
@@ -1175,17 +1387,17 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Opening balance: invoiced − paid before period start
|
// Opening balance: invoiced − paid before period start
|
||||||
var preInvoiced = await _context.Invoices
|
var preInvoiced = await _context.Invoices
|
||||||
.Where(i => i.CustomerId == customerId
|
.Where(i => i.CompanyId == companyId && i.CustomerId == customerId
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
&& i.InvoiceDate < from)
|
&& i.InvoiceDate < from)
|
||||||
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
.SumAsync(i => (decimal?)i.Total) ?? 0;
|
||||||
var prePaid = await _context.Payments
|
var prePaid = await _context.Payments
|
||||||
.Where(p => p.Invoice.CustomerId == customerId
|
.Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& p.PaymentDate < from)
|
&& p.PaymentDate < from)
|
||||||
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
.SumAsync(p => (decimal?)p.Amount) ?? 0;
|
||||||
var preCredits = await _context.CreditMemoApplications
|
var preCredits = await _context.CreditMemoApplications
|
||||||
.Where(a => a.Invoice.CustomerId == customerId && a.AppliedDate < from)
|
.Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId && a.AppliedDate < from)
|
||||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
|
||||||
var openingBalance = preInvoiced - prePaid - preCredits;
|
var openingBalance = preInvoiced - prePaid - preCredits;
|
||||||
@@ -1194,7 +1406,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
var lines = new List<StatementLineDto>();
|
var lines = new List<StatementLineDto>();
|
||||||
|
|
||||||
var periodInvoices = await _context.Invoices
|
var periodInvoices = await _context.Invoices
|
||||||
.Where(i => i.CustomerId == customerId
|
.Where(i => i.CompanyId == companyId && i.CustomerId == customerId
|
||||||
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
&& i.InvoiceDate >= from && i.InvoiceDate <= toEnd)
|
||||||
.AsNoTracking().ToListAsync();
|
.AsNoTracking().ToListAsync();
|
||||||
@@ -1211,7 +1423,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
var periodPayments = await _context.Payments
|
var periodPayments = await _context.Payments
|
||||||
.Include(p => p.Invoice)
|
.Include(p => p.Invoice)
|
||||||
.Where(p => p.Invoice.CustomerId == customerId
|
.Where(p => p.CompanyId == companyId && p.Invoice.CustomerId == customerId
|
||||||
&& p.Invoice.Status != InvoiceStatus.Voided
|
&& p.Invoice.Status != InvoiceStatus.Voided
|
||||||
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
&& p.PaymentDate >= from && p.PaymentDate <= toEnd)
|
||||||
.AsNoTracking().ToListAsync();
|
.AsNoTracking().ToListAsync();
|
||||||
@@ -1229,7 +1441,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
var periodCredits = await _context.CreditMemoApplications
|
var periodCredits = await _context.CreditMemoApplications
|
||||||
.Include(a => a.Invoice)
|
.Include(a => a.Invoice)
|
||||||
.Include(a => a.CreditMemo)
|
.Include(a => a.CreditMemo)
|
||||||
.Where(a => a.Invoice.CustomerId == customerId
|
.Where(a => a.CompanyId == companyId && a.Invoice.CustomerId == customerId
|
||||||
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
|
&& a.AppliedDate >= from && a.AppliedDate <= toEnd)
|
||||||
.AsNoTracking().ToListAsync();
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
@@ -1280,15 +1492,15 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
// Opening balance: bills − payments − credits before period start
|
// Opening balance: bills − payments − credits before period start
|
||||||
var preBills = await _context.Bills
|
var preBills = await _context.Bills
|
||||||
.Where(b => b.VendorId == vendorId
|
.Where(b => b.CompanyId == companyId && b.VendorId == vendorId
|
||||||
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||||
&& b.BillDate < from)
|
&& b.BillDate < from)
|
||||||
.SumAsync(b => (decimal?)b.Total) ?? 0;
|
.SumAsync(b => (decimal?)b.Total) ?? 0;
|
||||||
var prePayments = await _context.BillPayments
|
var prePayments = await _context.BillPayments
|
||||||
.Where(bp => bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
|
.Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId && bp.PaymentDate < from)
|
||||||
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
.SumAsync(bp => (decimal?)bp.Amount) ?? 0;
|
||||||
var preVcApplied = await _context.VendorCreditApplications
|
var preVcApplied = await _context.VendorCreditApplications
|
||||||
.Where(vca => vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
|
.Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId && vca.AppliedDate < from)
|
||||||
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
.SumAsync(vca => (decimal?)vca.Amount) ?? 0;
|
||||||
|
|
||||||
var openingBalance = preBills - prePayments - preVcApplied;
|
var openingBalance = preBills - prePayments - preVcApplied;
|
||||||
@@ -1296,7 +1508,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
var lines = new List<StatementLineDto>();
|
var lines = new List<StatementLineDto>();
|
||||||
|
|
||||||
var periodBills = await _context.Bills
|
var periodBills = await _context.Bills
|
||||||
.Where(b => b.VendorId == vendorId
|
.Where(b => b.CompanyId == companyId && b.VendorId == vendorId
|
||||||
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
&& b.Status != BillStatus.Draft && b.Status != BillStatus.Voided
|
||||||
&& b.BillDate >= from && b.BillDate <= toEnd)
|
&& b.BillDate >= from && b.BillDate <= toEnd)
|
||||||
.AsNoTracking().ToListAsync();
|
.AsNoTracking().ToListAsync();
|
||||||
@@ -1313,7 +1525,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
|
|
||||||
var periodPayments = await _context.BillPayments
|
var periodPayments = await _context.BillPayments
|
||||||
.Include(bp => bp.Bill)
|
.Include(bp => bp.Bill)
|
||||||
.Where(bp => bp.Bill.VendorId == vendorId
|
.Where(bp => bp.CompanyId == companyId && bp.Bill.VendorId == vendorId
|
||||||
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
&& bp.PaymentDate >= from && bp.PaymentDate <= toEnd)
|
||||||
.AsNoTracking().ToListAsync();
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
@@ -1330,7 +1542,7 @@ public class FinancialReportService : IFinancialReportService
|
|||||||
var periodVcApplied = await _context.VendorCreditApplications
|
var periodVcApplied = await _context.VendorCreditApplications
|
||||||
.Include(vca => vca.VendorCredit)
|
.Include(vca => vca.VendorCredit)
|
||||||
.Include(vca => vca.Bill)
|
.Include(vca => vca.Bill)
|
||||||
.Where(vca => vca.Bill.VendorId == vendorId
|
.Where(vca => vca.CompanyId == companyId && vca.Bill.VendorId == vendorId
|
||||||
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
|
&& vca.AppliedDate >= from && vca.AppliedDate <= toEnd)
|
||||||
.AsNoTracking().ToListAsync();
|
.AsNoTracking().ToListAsync();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Anthropic.SDK.Messaging;
|
|||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
|
|
||||||
namespace PowderCoating.Infrastructure.Services;
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
@@ -541,18 +542,20 @@ Rules:
|
|||||||
|
|
||||||
// Targeted prompt: we only need cure specs from this document
|
// Targeted prompt: we only need cure specs from this document
|
||||||
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
|
const string curePrompt = @"You are reading a Technical Data Sheet (TDS) for a powder coating product.
|
||||||
Extract ONLY the cure schedule. Respond with a valid JSON object — no markdown, no explanation:
|
Extract the cure schedule and the specific gravity. Respond with a valid JSON object — no markdown, no explanation:
|
||||||
|
|
||||||
{
|
{
|
||||||
""cureTemperatureF"": number or null,
|
""cureTemperatureF"": number or null,
|
||||||
""cureTimeMinutes"": number or null,
|
""cureTimeMinutes"": number or null,
|
||||||
""reasoning"": ""one sentence: what cure schedule you found""
|
""specificGravity"": number or null,
|
||||||
|
""reasoning"": ""one sentence: what cure schedule and specific gravity you found""
|
||||||
}
|
}
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325–400 °F.
|
- cureTemperatureF: the recommended cure temperature in °F. Convert from °C if needed (multiply by 1.8 + 32). If a range is given use the midpoint. Most powders cure 325–400 °F.
|
||||||
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
- cureTimeMinutes: the hold time at cure temperature in minutes (NOT total oven time). Typically 10–20 min.
|
||||||
- If neither value can be found in the document, return null for both.";
|
- specificGravity: the specific gravity / density value from the TDS (often labeled ""Specific Gravity"" or ""Density""). Typically 1.2–1.8. Null if not stated.
|
||||||
|
- Return null for any value not found in the document.";
|
||||||
|
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
sb.AppendLine("Technical Data Sheet content:");
|
sb.AppendLine("Technical Data Sheet content:");
|
||||||
@@ -603,11 +606,12 @@ Rules:
|
|||||||
Success = true,
|
Success = true,
|
||||||
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
CureTemperatureF = GetDecimal(parsed, "cureTemperatureF"),
|
||||||
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
CureTimeMinutes = GetInt(parsed, "cureTimeMinutes"),
|
||||||
|
SpecificGravity = GetDecimal(parsed, "specificGravity"),
|
||||||
Reasoning = GetString(parsed, "reasoning"),
|
Reasoning = GetString(parsed, "reasoning"),
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("TDS cure lookup for {Url}: temp={Temp}°F, time={Time}min ({Reasoning})",
|
_logger.LogInformation("TDS spec lookup for {Url}: temp={Temp}°F, time={Time}min, sg={Sg} ({Reasoning})",
|
||||||
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.Reasoning);
|
tdsUrl, result.CureTemperatureF, result.CureTimeMinutes, result.SpecificGravity, result.Reasoning);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -617,6 +621,58 @@ Rules:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> EnsureCatalogTdsSpecsAsync(PowderCatalogItem catalog)
|
||||||
|
{
|
||||||
|
// Already enriched, or nothing to read from. Specific gravity is the trigger: it's never in
|
||||||
|
// the API feed, so its absence means this item hasn't been TDS-enriched yet.
|
||||||
|
if (catalog == null || catalog.SpecificGravity.HasValue || string.IsNullOrWhiteSpace(catalog.TdsUrl))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var tds = await FetchTdsCureSpecsAsync(catalog.TdsUrl, catalog.ColorName);
|
||||||
|
if (!tds.Success)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
if (tds.SpecificGravity is > 0)
|
||||||
|
{
|
||||||
|
catalog.SpecificGravity = tds.SpecificGravity;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!catalog.CureTemperatureF.HasValue && tds.CureTemperatureF.HasValue)
|
||||||
|
{
|
||||||
|
catalog.CureTemperatureF = tds.CureTemperatureF;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (!catalog.CureTimeMinutes.HasValue && tds.CureTimeMinutes.HasValue)
|
||||||
|
{
|
||||||
|
catalog.CureTimeMinutes = tds.CureTimeMinutes;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
// Derive theoretical coverage once specific gravity is known.
|
||||||
|
if (!catalog.CoverageSqFtPerLb.HasValue && catalog.SpecificGravity is > 0)
|
||||||
|
{
|
||||||
|
catalog.CoverageSqFtPerLb = Math.Round(
|
||||||
|
TheoreticalCoverageConstant / (catalog.SpecificGravity.Value * DefaultCoverageThicknessMils),
|
||||||
|
2, MidpointRounding.AwayFromZero);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
catalog.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await _unitOfWork.PowderCatalog.UpdateAsync(catalog);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Lazily enriched catalog item {Vendor} {Sku} from TDS: sg={Sg}, cure={Temp}F/{Time}min, coverage={Cov}",
|
||||||
|
catalog.VendorName, catalog.Sku, catalog.SpecificGravity, catalog.CureTemperatureF,
|
||||||
|
catalog.CureTimeMinutes, catalog.CoverageSqFtPerLb);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
// ── Manufacturer URL pattern: build direct product page URL ───────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using PowderCoating.Application.DTOs.Accounting;
|
using PowderCoating.Application.DTOs.Accounting;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Accounting;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
|
||||||
@@ -199,6 +200,43 @@ public class LedgerService : ILedgerService
|
|||||||
LinkId = inv.Id
|
LinkId = inv.Id
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable) ──
|
||||||
|
// The revenue portion debits Sales Returns; the tax portion debits the invoice's sales-tax
|
||||||
|
// account (relieving the liability). Cash leaving the bank is handled in the bank section above.
|
||||||
|
// Store-credit refunds are excluded — they post via CreditMemo, not the GL (see CancelRefund).
|
||||||
|
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
|
||||||
|
{
|
||||||
|
var saleReversingRefunds = await _context.Refunds
|
||||||
|
.Include(r => r.Invoice)
|
||||||
|
.Where(r => !r.IsDeleted && r.Invoice != null
|
||||||
|
&& r.RefundMethod != PaymentMethod.StoreCredit
|
||||||
|
&& r.RefundDate >= fromDate && r.RefundDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var r in saleReversingRefunds)
|
||||||
|
{
|
||||||
|
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
|
||||||
|
|
||||||
|
if (account.AccountNumber == "4960" && returnsPortion != 0)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
|
||||||
|
Description = $"Sales return — {r.Invoice.InvoiceNumber}",
|
||||||
|
Debit = returnsPortion, Credit = 0,
|
||||||
|
LinkController = "Invoices", LinkId = r.InvoiceId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (r.Invoice.SalesTaxAccountId == accountId && taxPortion != 0)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = r.RefundDate, Reference = r.Reference ?? $"REF-{r.Id}", Source = "Refund",
|
||||||
|
Description = $"Tax refunded — {r.Invoice.InvoiceNumber}",
|
||||||
|
Debit = taxPortion, Credit = 0,
|
||||||
|
LinkController = "Invoices", LinkId = r.InvoiceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 6. Direct expenses categorized to this account (DEBIT) ────────────
|
// ── 6. Direct expenses categorized to this account (DEBIT) ────────────
|
||||||
// e.g. Expense account 6200 receives direct expense entries
|
// e.g. Expense account 6200 receives direct expense entries
|
||||||
var expensesTo = await _context.Expenses
|
var expensesTo = await _context.Expenses
|
||||||
@@ -312,24 +350,29 @@ public class LedgerService : ILedgerService
|
|||||||
LinkId = cm.InvoiceId
|
LinkId = cm.InvoiceId
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refunds re-open AR (DEBIT — customer owes again after refund)
|
// Gift-certificate redemptions reduce open AR (CREDIT) — ApplyGiftCertificate posts DR 2500 / CR AR.
|
||||||
var arRefunds = await _context.Refunds
|
var arGcRedemptions = await _context.GiftCertificateRedemptions
|
||||||
.Include(r => r.Invoice)
|
.Include(r => r.Invoice)
|
||||||
.Where(r => r.RefundDate >= fromDate && r.RefundDate <= toDate && !r.IsDeleted)
|
.Include(r => r.GiftCertificate)
|
||||||
|
.Where(r => !r.IsDeleted && r.RedeemedDate >= fromDate && r.RedeemedDate <= toDate
|
||||||
|
&& r.Invoice.Status != InvoiceStatus.Voided)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
foreach (var r in arRefunds)
|
foreach (var r in arGcRedemptions)
|
||||||
entries.Add(new LedgerEntryDto
|
entries.Add(new LedgerEntryDto
|
||||||
{
|
{
|
||||||
Date = r.RefundDate,
|
Date = r.RedeemedDate,
|
||||||
Reference = r.Reference ?? $"REF-{r.Id}",
|
Reference = r.GiftCertificate?.CertificateCode ?? $"GC-{r.GiftCertificateId}",
|
||||||
Source = "Refund",
|
Source = "Gift Certificate",
|
||||||
Description = r.Reason,
|
Description = $"GC redeemed on {r.Invoice?.InvoiceNumber}",
|
||||||
Debit = r.Amount,
|
Debit = 0,
|
||||||
Credit = 0,
|
Credit = r.AmountRedeemed,
|
||||||
LinkController = "Invoices",
|
LinkController = "Invoices",
|
||||||
LinkId = r.InvoiceId
|
LinkId = r.InvoiceId
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// NOTE: cash refunds no longer touch AR. Under the "reverse the sale" model they debit
|
||||||
|
// Sales Returns + Sales Tax Payable and credit the bank (see section 5b above).
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
// ── 9. Accounts Payable ────────────────────────────────────────────────
|
||||||
@@ -473,6 +516,125 @@ public class LedgerService : ILedgerService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 12b. Customer Credits liability (account 2350) ────────────────────
|
||||||
|
// CR when a credit memo (incl. store-credit refund) is issued; DR when applied to an invoice.
|
||||||
|
// Voided memos are excluded (their issue/void net to zero).
|
||||||
|
if (account.AccountNumber == "2350")
|
||||||
|
{
|
||||||
|
var memosIssued = await _context.CreditMemos
|
||||||
|
.Where(m => m.Status != CreditMemoStatus.Voided
|
||||||
|
&& m.IssueDate >= fromDate && m.IssueDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var m in memosIssued)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = m.IssueDate, Reference = m.MemoNumber,
|
||||||
|
Source = "Credit Memo", Description = "Store credit issued",
|
||||||
|
Debit = 0, Credit = m.Amount,
|
||||||
|
LinkController = "CreditMemos", LinkId = m.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
var memosApplied = await _context.CreditMemoApplications
|
||||||
|
.Include(a => a.CreditMemo).Include(a => a.Invoice)
|
||||||
|
.Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided
|
||||||
|
&& a.AppliedDate >= fromDate && a.AppliedDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var a in memosApplied)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = a.AppliedDate, Reference = a.CreditMemo?.MemoNumber ?? $"CM-{a.CreditMemoId}",
|
||||||
|
Source = "Credit Applied", Description = $"Applied to {a.Invoice?.InvoiceNumber}",
|
||||||
|
Debit = a.AmountApplied, Credit = 0,
|
||||||
|
LinkController = "Invoices", LinkId = a.InvoiceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 12c. Sales Discounts contra-revenue (account 4950) ────────────────
|
||||||
|
// Mirrors the actual postings made by AccountBalanceService so a balance recompute reproduces
|
||||||
|
// the stored CurrentBalance (otherwise "Recalculate Balances" would wipe 4950 down to JE-only):
|
||||||
|
// • Invoice discounts → DR 4950 at invoice date (InvoicesController invoice create/edit).
|
||||||
|
// • Credit memo issuance → DR 4950 = full memo amount at issue (CreditMemosController.Create
|
||||||
|
// and the store-credit refund path, which both create a CreditMemo row).
|
||||||
|
// • Credit memo void → CR 4950 = unapplied remainder at void (reverses the unused part).
|
||||||
|
// Keep this in step with FinancialReportService's 4950 computation (discountsByAcct + cmContraRevenue).
|
||||||
|
if (account.AccountNumber == "4950")
|
||||||
|
{
|
||||||
|
var discountInvoices = await _context.Invoices
|
||||||
|
.Where(i => i.DiscountAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var inv in discountInvoices)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = inv.InvoiceDate, Reference = inv.InvoiceNumber,
|
||||||
|
Source = "Invoice", Description = $"Discount on {inv.InvoiceNumber}",
|
||||||
|
Debit = inv.DiscountAmount, Credit = 0,
|
||||||
|
LinkController = "Invoices", LinkId = inv.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
var discountMemosIssued = await _context.CreditMemos
|
||||||
|
.Where(m => m.IssueDate >= fromDate && m.IssueDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var m in discountMemosIssued)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = m.IssueDate, Reference = m.MemoNumber,
|
||||||
|
Source = "Credit Memo", Description = "Store credit issued (contra-revenue)",
|
||||||
|
Debit = m.Amount, Credit = 0,
|
||||||
|
LinkController = "CreditMemos", LinkId = m.Id
|
||||||
|
});
|
||||||
|
|
||||||
|
var discountMemosVoided = await _context.CreditMemos
|
||||||
|
.Where(m => m.Status == CreditMemoStatus.Voided
|
||||||
|
&& m.UpdatedAt >= fromDate && m.UpdatedAt <= toDate
|
||||||
|
&& m.Amount > m.AmountApplied)
|
||||||
|
.ToListAsync();
|
||||||
|
foreach (var m in discountMemosVoided)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = m.UpdatedAt.GetValueOrDefault(), Reference = m.MemoNumber,
|
||||||
|
Source = "Credit Memo Voided", Description = "Reversed unapplied store credit",
|
||||||
|
Debit = 0, Credit = m.Amount - m.AmountApplied,
|
||||||
|
LinkController = "CreditMemos", LinkId = m.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 12d. Inventory consumption COGS (DR COGS / CR Inventory) ──────────
|
||||||
|
// When an item with both a COGS and an Inventory account is consumed (JobUsage/Waste — the only
|
||||||
|
// two transaction types created at the COGS-posting sites), JobsController/InventoryController post
|
||||||
|
// DR COGS / CR Inventory at the transaction's TotalCost. Reproduce it here so a balance recompute
|
||||||
|
// matches the posting and the trial balance stays balanced. TotalCost is stored positive.
|
||||||
|
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
|
||||||
|
{
|
||||||
|
var consumption = await _context.InventoryTransactions
|
||||||
|
.Include(t => t.InventoryItem)
|
||||||
|
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||||
|
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||||
|
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
|
||||||
|
&& t.TransactionDate >= fromDate && t.TransactionDate <= toDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var t in consumption)
|
||||||
|
{
|
||||||
|
var amount = Math.Abs(t.TotalCost);
|
||||||
|
if (t.InventoryItem.CogsAccountId == accountId)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
|
||||||
|
Source = "Inventory Usage", Description = $"COGS — {t.InventoryItem.Name}",
|
||||||
|
Debit = amount, Credit = 0, LinkController = "Inventory", LinkId = t.InventoryItemId
|
||||||
|
});
|
||||||
|
if (t.InventoryItem.InventoryAccountId == accountId)
|
||||||
|
entries.Add(new LedgerEntryDto
|
||||||
|
{
|
||||||
|
Date = t.TransactionDate, Reference = t.Reference ?? $"INV-{t.Id}",
|
||||||
|
Source = "Inventory Usage", Description = $"Inventory relieved — {t.InventoryItem.Name}",
|
||||||
|
Debit = 0, Credit = amount, LinkController = "Inventory", LinkId = t.InventoryItemId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 10. Journal Entry lines touching this account ──────────────────
|
// ── 10. Journal Entry lines touching this account ──────────────────
|
||||||
var jeLines = await _context.JournalEntryLines
|
var jeLines = await _context.JournalEntryLines
|
||||||
.Include(l => l.JournalEntry)
|
.Include(l => l.JournalEntry)
|
||||||
@@ -594,6 +756,27 @@ public class LedgerService : ILedgerService
|
|||||||
&& i.InvoiceDate < beforeDate)
|
&& i.InvoiceDate < beforeDate)
|
||||||
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
|
.SumAsync(i => (decimal?)i.TaxAmount) ?? 0;
|
||||||
|
|
||||||
|
// 5b. Cash refunds reverse the sale (DR Sales Returns 4960 + DR Sales Tax Payable). Store-credit
|
||||||
|
// refunds are excluded (no GL posting). Mirrors section 5b in GetAccountLedgerAsync.
|
||||||
|
if (account.AccountNumber == "4960" || account.AccountType == AccountType.Liability)
|
||||||
|
{
|
||||||
|
var priorRefunds = await _context.Refunds
|
||||||
|
.Include(r => r.Invoice)
|
||||||
|
.Where(r => !r.IsDeleted && r.Invoice != null
|
||||||
|
&& r.RefundMethod != PaymentMethod.StoreCredit
|
||||||
|
&& r.RefundDate < beforeDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var r in priorRefunds)
|
||||||
|
{
|
||||||
|
var (returnsPortion, taxPortion) = RefundAllocation.Split(r.Amount, r.Invoice.TaxAmount, r.Invoice.Total);
|
||||||
|
if (account.AccountNumber == "4960")
|
||||||
|
debits += returnsPortion;
|
||||||
|
if (r.Invoice.SalesTaxAccountId == accountId)
|
||||||
|
debits += taxPortion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 6. Direct expenses categorized to this account (DEBIT)
|
// 6. Direct expenses categorized to this account (DEBIT)
|
||||||
debits += await _context.Expenses
|
debits += await _context.Expenses
|
||||||
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
|
.Where(e => e.ExpenseAccountId == accountId && e.Date < beforeDate)
|
||||||
@@ -624,9 +807,13 @@ public class LedgerService : ILedgerService
|
|||||||
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
.Where(a => a.AppliedDate < beforeDate && a.Invoice.Status != InvoiceStatus.Voided)
|
||||||
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
|
||||||
debits += await _context.Refunds
|
// Gift-certificate redemptions credit AR (DR 2500 / CR AR), same as in GetAccountLedgerAsync.
|
||||||
.Where(r => !r.IsDeleted && r.RefundDate < beforeDate)
|
credits += await _context.GiftCertificateRedemptions
|
||||||
.SumAsync(r => (decimal?)r.Amount) ?? 0;
|
.Where(r => !r.IsDeleted && r.RedeemedDate < beforeDate && r.Invoice.Status != InvoiceStatus.Voided)
|
||||||
|
.SumAsync(r => (decimal?)r.AmountRedeemed) ?? 0;
|
||||||
|
|
||||||
|
// NOTE: cash refunds no longer debit AR — they reverse the sale (Sales Returns + Sales Tax),
|
||||||
|
// handled in section 5b above.
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. Accounts Payable
|
// 9. Accounts Payable
|
||||||
@@ -674,6 +861,55 @@ public class LedgerService : ILedgerService
|
|||||||
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
.SumAsync(d => (decimal?)d.Amount) ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 12b. Customer Credits liability (account 2350)
|
||||||
|
if (account.AccountNumber == "2350")
|
||||||
|
{
|
||||||
|
credits += await _context.CreditMemos
|
||||||
|
.Where(m => m.Status != CreditMemoStatus.Voided && m.IssueDate < beforeDate)
|
||||||
|
.SumAsync(m => (decimal?)m.Amount) ?? 0;
|
||||||
|
debits += await _context.CreditMemoApplications
|
||||||
|
.Where(a => a.CreditMemo.Status != CreditMemoStatus.Voided && a.AppliedDate < beforeDate)
|
||||||
|
.SumAsync(a => (decimal?)a.AmountApplied) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12c. Sales Discounts contra-revenue (account 4950). Mirrors section 12c in GetAccountLedgerAsync
|
||||||
|
// so the prior-period opening balance matches the actual postings (invoice discounts + memo issues,
|
||||||
|
// less the unapplied remainder of voided memos).
|
||||||
|
if (account.AccountNumber == "4950")
|
||||||
|
{
|
||||||
|
debits += await _context.Invoices
|
||||||
|
.Where(i => i.DiscountAmount > 0
|
||||||
|
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
|
||||||
|
&& i.InvoiceDate < beforeDate)
|
||||||
|
.SumAsync(i => (decimal?)i.DiscountAmount) ?? 0;
|
||||||
|
debits += await _context.CreditMemos
|
||||||
|
.Where(m => m.IssueDate < beforeDate)
|
||||||
|
.SumAsync(m => (decimal?)m.Amount) ?? 0;
|
||||||
|
credits += await _context.CreditMemos
|
||||||
|
.Where(m => m.Status == CreditMemoStatus.Voided && m.UpdatedAt < beforeDate && m.Amount > m.AmountApplied)
|
||||||
|
.SumAsync(m => (decimal?)(m.Amount - m.AmountApplied)) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12d. Inventory consumption COGS (DR COGS / CR Inventory). Mirrors section 12d in
|
||||||
|
// GetAccountLedgerAsync so the prior-period opening balance matches the posting.
|
||||||
|
if (account.AccountType == AccountType.CostOfGoods || account.AccountSubType == AccountSubType.Inventory)
|
||||||
|
{
|
||||||
|
var priorConsumption = await _context.InventoryTransactions
|
||||||
|
.Include(t => t.InventoryItem)
|
||||||
|
.Where(t => (t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste)
|
||||||
|
&& t.InventoryItem.CogsAccountId != null && t.InventoryItem.InventoryAccountId != null
|
||||||
|
&& (t.InventoryItem.CogsAccountId == accountId || t.InventoryItem.InventoryAccountId == accountId)
|
||||||
|
&& t.TransactionDate < beforeDate)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
foreach (var t in priorConsumption)
|
||||||
|
{
|
||||||
|
var amount = Math.Abs(t.TotalCost);
|
||||||
|
if (t.InventoryItem.CogsAccountId == accountId) debits += amount;
|
||||||
|
if (t.InventoryItem.InventoryAccountId == accountId) credits += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 10. Posted journal entry lines touching this account (prior to period)
|
// 10. Posted journal entry lines touching this account (prior to period)
|
||||||
debits += await _context.JournalEntryLines
|
debits += await _context.JournalEntryLines
|
||||||
.Where(l => l.AccountId == accountId
|
.Where(l => l.AccountId == accountId
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Entities;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace PowderCoating.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Single upsert path for the platform <see cref="PowderCatalogItem"/> master list, shared by the
|
||||||
|
/// JSON file import and the Columbia API sync. Match key is (VendorName, SKU), case-insensitive.
|
||||||
|
/// </summary>
|
||||||
|
public class PowderCatalogUpsertService : IPowderCatalogUpsertService
|
||||||
|
{
|
||||||
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
|
private readonly ILogger<PowderCatalogUpsertService> _logger;
|
||||||
|
|
||||||
|
public PowderCatalogUpsertService(IUnitOfWork unitOfWork, ILogger<PowderCatalogUpsertService> logger)
|
||||||
|
{
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<PowderCatalogUpsertResult> UpsertAsync(
|
||||||
|
IReadOnlyList<PowderCatalogItem> incoming,
|
||||||
|
DateTime runTimestamp,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var result = new PowderCatalogUpsertResult();
|
||||||
|
|
||||||
|
// Load existing rows for just the vendors we're touching, keyed by (vendor|sku) lower-cased.
|
||||||
|
var vendorNames = incoming
|
||||||
|
.Select(i => i.VendorName)
|
||||||
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => vendorNames.Contains(p.VendorName)))
|
||||||
|
.ToDictionary(KeyOf, StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
var toAdd = new List<PowderCatalogItem>();
|
||||||
|
|
||||||
|
foreach (var item in incoming)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Sku) || string.IsNullOrWhiteSpace(item.ColorName))
|
||||||
|
{
|
||||||
|
result.Skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.TryGetValue(KeyOf(item), out var record))
|
||||||
|
{
|
||||||
|
if (ApplyFeedFields(record, item))
|
||||||
|
{
|
||||||
|
record.UpdatedAt = runTimestamp;
|
||||||
|
record.LastSyncedAt = runTimestamp;
|
||||||
|
await _unitOfWork.PowderCatalog.UpdateAsync(record);
|
||||||
|
result.Updated++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result.Unchanged++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
item.CreatedAt = runTimestamp;
|
||||||
|
item.LastSyncedAt = runTimestamp;
|
||||||
|
toAdd.Add(item);
|
||||||
|
result.Inserted++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toAdd.Count > 0)
|
||||||
|
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
|
||||||
|
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// Push current catalog price + product data down to any tenant inventory linked to these
|
||||||
|
// catalog rows, so quotes reflect the current price.
|
||||||
|
var propagated = await PropagateToLinkedInventoryAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Powder catalog upsert: {Inserted} inserted, {Updated} updated, {Unchanged} unchanged, {Skipped} skipped; {Propagated} linked inventory item(s) refreshed.",
|
||||||
|
result.Inserted, result.Updated, result.Unchanged, result.Skipped, propagated);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Keeps tenant inventory in step with the catalog (across all companies): first self-heals by
|
||||||
|
/// linking any unlinked item to its catalog row by identity, then refreshes every linked item
|
||||||
|
/// with the catalog's current price and product data. Returns the number of items touched.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<int> PropagateToLinkedInventoryAsync()
|
||||||
|
{
|
||||||
|
var linkedCount = await LinkUnlinkedInventoryAsync();
|
||||||
|
var refreshedCount = await RefreshLinkedInventoryAsync();
|
||||||
|
return linkedCount + refreshedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Self-heals the catalog link: finds inventory items with no <see cref="InventoryItem.PowderCatalogItemId"/>
|
||||||
|
/// that match a catalog row by Manufacturer + ManufacturerPartNumber (the catalog SKU), sets the
|
||||||
|
/// FK, and applies the catalog price/product data. Only links on a confident match (exact SKU,
|
||||||
|
/// matching vendor, or a single unambiguous candidate) so it never mis-links. Returns the count
|
||||||
|
/// newly linked. This backfills items created before linking existed, on every environment, with
|
||||||
|
/// no manual step.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> LinkUnlinkedInventoryAsync()
|
||||||
|
{
|
||||||
|
var unlinked = (await _unitOfWork.InventoryItems.FindAsync(
|
||||||
|
i => i.PowderCatalogItemId == null
|
||||||
|
&& i.Manufacturer != null && i.Manufacturer != ""
|
||||||
|
&& i.ManufacturerPartNumber != null && i.ManufacturerPartNumber != "",
|
||||||
|
ignoreQueryFilters: true)).ToList();
|
||||||
|
if (unlinked.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var partNumbers = unlinked.Select(i => i.ManufacturerPartNumber!).Distinct().ToList();
|
||||||
|
var bySku = (await _unitOfWork.PowderCatalog.FindAsync(p => partNumbers.Contains(p.Sku)))
|
||||||
|
.GroupBy(c => c.Sku, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
|
||||||
|
if (bySku.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var linked = 0;
|
||||||
|
foreach (var inv in unlinked)
|
||||||
|
{
|
||||||
|
if (!bySku.TryGetValue(inv.ManufacturerPartNumber!, out var candidates))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var mfr = inv.Manufacturer!.Trim().ToLower();
|
||||||
|
var match = candidates.FirstOrDefault(c => c.VendorName.ToLower().Contains(mfr))
|
||||||
|
?? (candidates.Count == 1 ? candidates[0] : null);
|
||||||
|
if (match == null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
inv.PowderCatalogItemId = match.Id;
|
||||||
|
ApplyCatalogToLinkedInventory(inv, match);
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
||||||
|
linked++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (linked > 0)
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return linked;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes every tenant inventory item linked to a powder catalog row (across all companies)
|
||||||
|
/// with the catalog's current list price and product data. Sets
|
||||||
|
/// <see cref="InventoryItem.CatalogReferencePrice"/> (the QUOTING price) and product spec/doc
|
||||||
|
/// fields, but NEVER the cost basis (UnitCost/AverageCost/LastPurchasePrice), quantity, notes,
|
||||||
|
/// image, location, or stock levels — those are tenant-owned. EF persists only items that
|
||||||
|
/// actually changed, so this is a cheap no-op when nothing moved. Returns the number updated.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> RefreshLinkedInventoryAsync()
|
||||||
|
{
|
||||||
|
var linked = (await _unitOfWork.InventoryItems.FindAsync(
|
||||||
|
i => i.PowderCatalogItemId != null, ignoreQueryFilters: true)).ToList();
|
||||||
|
if (linked.Count == 0)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
var catalogIds = linked.Select(i => i.PowderCatalogItemId!.Value).Distinct().ToList();
|
||||||
|
var catalogById = (await _unitOfWork.PowderCatalog.FindAsync(p => catalogIds.Contains(p.Id)))
|
||||||
|
.ToDictionary(p => p.Id);
|
||||||
|
|
||||||
|
var updated = 0;
|
||||||
|
foreach (var inv in linked)
|
||||||
|
{
|
||||||
|
if (!catalogById.TryGetValue(inv.PowderCatalogItemId!.Value, out var cat))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (ApplyCatalogToLinkedInventory(inv, cat))
|
||||||
|
{
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
||||||
|
updated++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated > 0)
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the catalog's current price and product data onto a linked inventory item, returning
|
||||||
|
/// true if anything changed. Sets the quoting reference price (only when the catalog has a real
|
||||||
|
/// price > 0) and refreshes product/spec fields where the catalog has a value — never erasing
|
||||||
|
/// tenant data with catalog nulls, and never touching cost basis, quantity, notes, image, or
|
||||||
|
/// stock levels.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ApplyCatalogToLinkedInventory(InventoryItem inv, PowderCatalogItem cat)
|
||||||
|
{
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
// Quoting price (the point of this): keep the current catalog list price, separate from cost.
|
||||||
|
if (cat.UnitPrice > 0 && inv.CatalogReferencePrice != cat.UnitPrice)
|
||||||
|
{
|
||||||
|
inv.CatalogReferencePrice = cat.UnitPrice;
|
||||||
|
inv.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Product data — refresh from the catalog where it has a value (catalog is authoritative on
|
||||||
|
// these); do not null out a tenant value the catalog doesn't carry.
|
||||||
|
changed |= SetStrIfCatalogHas(() => inv.Description, v => inv.Description = v, cat.Description);
|
||||||
|
changed |= SetStrIfCatalogHas(() => inv.Finish, v => inv.Finish = v, cat.Finish);
|
||||||
|
changed |= SetStrIfCatalogHas(() => inv.ColorFamilies, v => inv.ColorFamilies = v, cat.ColorFamilies);
|
||||||
|
changed |= SetStrIfCatalogHas(() => inv.SdsUrl, v => inv.SdsUrl = v, cat.SdsUrl);
|
||||||
|
changed |= SetStrIfCatalogHas(() => inv.TdsUrl, v => inv.TdsUrl = v, cat.TdsUrl);
|
||||||
|
changed |= SetStrIfCatalogHas(() => inv.SpecPageUrl, v => inv.SpecPageUrl = v, cat.ProductUrl);
|
||||||
|
|
||||||
|
if (cat.CureTemperatureF.HasValue && inv.CureTemperatureF != cat.CureTemperatureF)
|
||||||
|
{ inv.CureTemperatureF = cat.CureTemperatureF; changed = true; }
|
||||||
|
if (cat.CureTimeMinutes.HasValue && inv.CureTimeMinutes != cat.CureTimeMinutes)
|
||||||
|
{ inv.CureTimeMinutes = cat.CureTimeMinutes; changed = true; }
|
||||||
|
if (cat.CoverageSqFtPerLb.HasValue && inv.CoverageSqFtPerLb != cat.CoverageSqFtPerLb)
|
||||||
|
{ inv.CoverageSqFtPerLb = cat.CoverageSqFtPerLb; changed = true; }
|
||||||
|
if (cat.SpecificGravity.HasValue && inv.SpecificGravity != cat.SpecificGravity)
|
||||||
|
{ inv.SpecificGravity = cat.SpecificGravity; changed = true; }
|
||||||
|
if (cat.TransferEfficiency.HasValue && inv.TransferEfficiency != cat.TransferEfficiency)
|
||||||
|
{ inv.TransferEfficiency = cat.TransferEfficiency; changed = true; }
|
||||||
|
if (cat.RequiresClearCoat == true && !inv.RequiresClearCoat)
|
||||||
|
{ inv.RequiresClearCoat = true; changed = true; }
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sets a string property from the catalog only when the catalog value is non-blank and differs.</summary>
|
||||||
|
private static bool SetStrIfCatalogHas(Func<string?> get, Action<string?> set, string? catalogValue)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(catalogValue) && !string.Equals(get(), catalogValue, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
set(catalogValue);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string KeyOf(PowderCatalogItem p) => $"{p.VendorName}|{p.Sku}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies feed-sourced fields from <paramref name="src"/> onto <paramref name="dest"/> and
|
||||||
|
/// returns true if anything changed. Deliberately leaves enrichment fields (SpecificGravity,
|
||||||
|
/// CoverageSqFtPerLb, TransferEfficiency, Finish) and lifecycle flags untouched — those are
|
||||||
|
/// owned by lazy TDS/AI enrichment and the discontinuation sweep, not the feed.
|
||||||
|
/// </summary>
|
||||||
|
private static bool ApplyFeedFields(PowderCatalogItem dest, PowderCatalogItem src)
|
||||||
|
{
|
||||||
|
var changed = false;
|
||||||
|
|
||||||
|
changed |= Set(() => dest.ColorName, v => dest.ColorName = v, src.ColorName);
|
||||||
|
changed |= Set(() => dest.Description, v => dest.Description = v, src.Description);
|
||||||
|
changed |= src.UnitPrice > 0 && dest.UnitPrice != src.UnitPrice && Assign(() => dest.UnitPrice = src.UnitPrice);
|
||||||
|
changed |= Set(() => dest.PriceTiersJson, v => dest.PriceTiersJson = v, src.PriceTiersJson);
|
||||||
|
changed |= Set(() => dest.ImageUrl, v => dest.ImageUrl = v, src.ImageUrl);
|
||||||
|
changed |= Set(() => dest.SdsUrl, v => dest.SdsUrl = v, src.SdsUrl);
|
||||||
|
changed |= Set(() => dest.TdsUrl, v => dest.TdsUrl = v, src.TdsUrl);
|
||||||
|
changed |= Set(() => dest.ApplicationGuideUrl, v => dest.ApplicationGuideUrl = v, src.ApplicationGuideUrl);
|
||||||
|
changed |= Set(() => dest.ProductUrl, v => dest.ProductUrl = v, src.ProductUrl);
|
||||||
|
changed |= Set(() => dest.ChemistryType, v => dest.ChemistryType = v, src.ChemistryType);
|
||||||
|
changed |= Set(() => dest.MilThickness, v => dest.MilThickness = v, src.MilThickness);
|
||||||
|
changed |= Set(() => dest.CureScheduleText, v => dest.CureScheduleText = v, src.CureScheduleText);
|
||||||
|
changed |= Set(() => dest.CureCurvesJson, v => dest.CureCurvesJson = v, src.CureCurvesJson);
|
||||||
|
changed |= src.CureTemperatureF.HasValue && dest.CureTemperatureF != src.CureTemperatureF && Assign(() => dest.CureTemperatureF = src.CureTemperatureF);
|
||||||
|
changed |= src.CureTimeMinutes.HasValue && dest.CureTimeMinutes != src.CureTimeMinutes && Assign(() => dest.CureTimeMinutes = src.CureTimeMinutes);
|
||||||
|
changed |= src.RequiresClearCoat.HasValue && dest.RequiresClearCoat != src.RequiresClearCoat && Assign(() => dest.RequiresClearCoat = src.RequiresClearCoat);
|
||||||
|
changed |= Set(() => dest.ColorFamilies, v => dest.ColorFamilies = v, src.ColorFamilies);
|
||||||
|
changed |= Set(() => dest.FormulationChanges, v => dest.FormulationChanges = v, src.FormulationChanges);
|
||||||
|
changed |= Set(() => dest.Category, v => dest.Category = v, src.Category);
|
||||||
|
changed |= Set(() => dest.Source, v => dest.Source = v, src.Source);
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sets a nullable-string property when the feed provides a non-blank value that differs.
|
||||||
|
/// Merge semantics: a blank incoming value is ignored, so a partial feed (e.g. the Prismatic
|
||||||
|
/// file import, which omits cure/chemistry) never nulls out existing data.
|
||||||
|
/// </summary>
|
||||||
|
private static bool Set(Func<string?> get, Action<string?> set, string? newValue)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(newValue))
|
||||||
|
return false;
|
||||||
|
if (!string.Equals(get(), newValue, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
set(newValue);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Helper so a value assignment can participate in a boolean OR chain.</summary>
|
||||||
|
private static bool Assign(Action assign)
|
||||||
|
{
|
||||||
|
assign();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,7 +60,15 @@ public partial class SeedDataService
|
|||||||
new Account { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, Description = "Amounts owed to suppliers and vendors", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "2000", Name = "Accounts Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.AccountsPayable, IsSystem = true, IsActive = true, Description = "Amounts owed to suppliers and vendors", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "2100", Name = "Credit Card Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = false, IsActive = true, Description = "Business credit card balance", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "2100", Name = "Credit Card Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.CreditCard, IsSystem = false, IsActive = true, Description = "Business credit card balance", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Sales tax collected and owed to government", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "2200", Name = "Sales Tax Payable", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Sales tax collected and owed to government", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "2300", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now },
|
// 2300 is the Customer Deposits liability — credited when a deposit is taken, debited when it is
|
||||||
|
// applied to an invoice (see DepositsController / InvoicesController, which resolve it by number).
|
||||||
|
// IsSystem because the GL posting code depends on it existing. Payroll lives at 2400 below.
|
||||||
|
new Account { AccountNumber = "2300", Name = "Customer Deposits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
new Account { AccountNumber = "2350", Name = "Customer Credits", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Store credit owed to customers (credit memos not yet applied)", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
new Account { AccountNumber = "2400", Name = "Payroll Liabilities", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = false, IsActive = true, Description = "Payroll taxes and withholdings owed", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
// 2500 Gift Certificate Liability — credited when a GC is issued, debited when redeemed/voided
|
||||||
|
// (resolved by number in GiftCertificatesController). IsSystem because the GL posting depends on it.
|
||||||
|
new Account { AccountNumber = "2500", Name = "Gift Certificate Liability", AccountType = AccountType.Liability, AccountSubType = AccountSubType.OtherCurrentLiability, IsSystem = true, IsActive = true, Description = "Outstanding gift certificate obligations owed to certificate holders", CompanyId = company.Id, CreatedAt = now },
|
||||||
new Account { AccountNumber = "2900", Name = "Business Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, Description = "Long-term equipment or business loan", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "2900", Name = "Business Loan", AccountType = AccountType.Liability, AccountSubType = AccountSubType.LongTermLiability, IsSystem = false, IsActive = true, Description = "Long-term equipment or business loan", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|
||||||
// ── EQUITY ────────────────────────────────────────────────────────
|
// ── EQUITY ────────────────────────────────────────────────────────
|
||||||
@@ -77,6 +85,7 @@ public partial class SeedDataService
|
|||||||
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
// A credit-normal account with a debit balance appears in the Trial Balance debit column,
|
||||||
// reducing net revenue to match the discounted AR amount that was posted.
|
// reducing net revenue to match the discounted AR amount that was posted.
|
||||||
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "4950", Name = "Sales Discounts", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for invoice discounts granted to customers", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
new Account { AccountNumber = "4960", Name = "Sales Returns & Allowances", AccountType = AccountType.Revenue, AccountSubType = AccountSubType.OtherIncome, IsSystem = true, IsActive = true, Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)", CompanyId = company.Id, CreatedAt = now },
|
||||||
|
|
||||||
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
// ── COST OF GOODS SOLD ────────────────────────────────────────────
|
||||||
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
new Account { AccountNumber = "5000", Name = "Cost of Goods Sold", AccountType = AccountType.CostOfGoods, AccountSubType = AccountSubType.CostOfGoodsSold, IsSystem = false, IsActive = true, Description = "Direct cost of services delivered", CompanyId = company.Id, CreatedAt = now },
|
||||||
@@ -141,6 +150,134 @@ public partial class SeedDataService
|
|||||||
added++;
|
added++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4960 Sales Returns & Allowances — contra-revenue account that receives the revenue portion
|
||||||
|
// of customer refunds under the "reverse the sale" model (DR Sales Returns + DR Sales Tax / CR Bank).
|
||||||
|
var has4960 = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "4960" && !a.IsDeleted);
|
||||||
|
|
||||||
|
if (!has4960)
|
||||||
|
{
|
||||||
|
_context.Set<Account>().Add(new Account
|
||||||
|
{
|
||||||
|
AccountNumber = "4960",
|
||||||
|
Name = "Sales Returns & Allowances",
|
||||||
|
AccountType = AccountType.Revenue,
|
||||||
|
AccountSubType = AccountSubType.OtherIncome,
|
||||||
|
IsSystem = true,
|
||||||
|
IsActive = true,
|
||||||
|
Description = "Contra-revenue for the revenue portion of customer refunds (reversed sales)",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2350 Customer Credits — liability for store credit owed to customers. Credited when a credit
|
||||||
|
// memo (incl. store-credit refunds) is issued; debited when applied to an invoice.
|
||||||
|
var has2350 = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2350" && !a.IsDeleted);
|
||||||
|
|
||||||
|
if (!has2350)
|
||||||
|
{
|
||||||
|
_context.Set<Account>().Add(new Account
|
||||||
|
{
|
||||||
|
AccountNumber = "2350",
|
||||||
|
Name = "Customer Credits",
|
||||||
|
AccountType = AccountType.Liability,
|
||||||
|
AccountSubType = AccountSubType.OtherCurrentLiability,
|
||||||
|
IsSystem = true,
|
||||||
|
IsActive = true,
|
||||||
|
Description = "Store credit owed to customers (credit memos not yet applied)",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2300 used to be seeded as "Payroll Liabilities" but the deposit GL posting code has always
|
||||||
|
// resolved 2300 by number and used it as the Customer Deposits liability — so the account was
|
||||||
|
// mislabeled on the balance sheet. Rename it to "Customer Deposits" and mark it system. Only
|
||||||
|
// touch accounts still carrying the old default name so a user's own rename is preserved.
|
||||||
|
var legacyDepositsAcct = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2300"
|
||||||
|
&& !a.IsDeleted && a.Name == "Payroll Liabilities");
|
||||||
|
if (legacyDepositsAcct != null)
|
||||||
|
{
|
||||||
|
legacyDepositsAcct.Name = "Customer Deposits";
|
||||||
|
legacyDepositsAcct.Description = "Deposits received from customers before an invoice is created; cleared when applied to an invoice";
|
||||||
|
legacyDepositsAcct.IsSystem = true;
|
||||||
|
legacyDepositsAcct.UpdatedAt = now;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2400 Payroll Liabilities — the payroll account displaced from 2300 (now Customer Deposits).
|
||||||
|
var has2400 = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2400" && !a.IsDeleted);
|
||||||
|
|
||||||
|
if (!has2400)
|
||||||
|
{
|
||||||
|
_context.Set<Account>().Add(new Account
|
||||||
|
{
|
||||||
|
AccountNumber = "2400",
|
||||||
|
Name = "Payroll Liabilities",
|
||||||
|
AccountType = AccountType.Liability,
|
||||||
|
AccountSubType = AccountSubType.OtherCurrentLiability,
|
||||||
|
IsSystem = false,
|
||||||
|
IsActive = true,
|
||||||
|
Description = "Payroll taxes and withholdings owed",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2500 has always been resolved by number as the Gift Certificate Liability (GiftCertificatesController),
|
||||||
|
// but the default-company seed created it as "Long-Term Loan" — so GC obligations were mislabeled there.
|
||||||
|
// Rename it (only where it still carries the old default name) and mark it system.
|
||||||
|
var legacyGcAcct = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.FirstOrDefaultAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500"
|
||||||
|
&& !a.IsDeleted && a.Name == "Long-Term Loan");
|
||||||
|
if (legacyGcAcct != null)
|
||||||
|
{
|
||||||
|
legacyGcAcct.Name = "Gift Certificate Liability";
|
||||||
|
legacyGcAcct.Description = "Outstanding gift certificate obligations owed to certificate holders";
|
||||||
|
legacyGcAcct.IsSystem = true;
|
||||||
|
legacyGcAcct.UpdatedAt = now;
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2500 Gift Certificate Liability — ensure it exists for companies that never got one (e.g. tenants
|
||||||
|
// onboarded after the AccountingGapsPhase2 migration ran). Without it, GC GL postings silently no-op.
|
||||||
|
var has2500 = await _context.Set<Account>()
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.AnyAsync(a => a.CompanyId == company.Id && a.AccountNumber == "2500" && !a.IsDeleted);
|
||||||
|
|
||||||
|
if (!has2500)
|
||||||
|
{
|
||||||
|
_context.Set<Account>().Add(new Account
|
||||||
|
{
|
||||||
|
AccountNumber = "2500",
|
||||||
|
Name = "Gift Certificate Liability",
|
||||||
|
AccountType = AccountType.Liability,
|
||||||
|
AccountSubType = AccountSubType.OtherCurrentLiability,
|
||||||
|
IsSystem = true,
|
||||||
|
IsActive = true,
|
||||||
|
Description = "Outstanding gift certificate obligations owed to certificate holders",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
CreatedAt = now
|
||||||
|
});
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using PowderCoating.Application.Constants;
|
||||||
|
using PowderCoating.Application.Interfaces;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs the Columbia Coatings catalog sync on a schedule. Wakes hourly and triggers a full sync
|
||||||
|
/// only when the master switch (<c>ColumbiaSyncEnabled</c>) is on and the configured interval
|
||||||
|
/// (<c>ColumbiaSyncIntervalDays</c>) has elapsed since the last successful run. A full sync is
|
||||||
|
/// cheap (~25 API calls), so an hourly due-check is negligible; the actual work runs at most once
|
||||||
|
/// per interval. No-ops quietly when disabled or unconfigured.
|
||||||
|
/// </summary>
|
||||||
|
public class ColumbiaCatalogSyncBackgroundService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<ColumbiaCatalogSyncBackgroundService> _logger;
|
||||||
|
|
||||||
|
private static readonly TimeSpan CheckInterval = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan StartupDelay = TimeSpan.FromMinutes(2);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uses <see cref="IServiceScopeFactory"/> because a <see cref="BackgroundService"/> is a
|
||||||
|
/// singleton and the sync service / platform settings are scoped.
|
||||||
|
/// </summary>
|
||||||
|
public ColumbiaCatalogSyncBackgroundService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<ColumbiaCatalogSyncBackgroundService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("ColumbiaCatalogSyncBackgroundService started.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(StartupDelay, stoppingToken);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await RunIfDueAsync(stoppingToken);
|
||||||
|
await Task.Delay(CheckInterval, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
// Shutting down — expected.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks the enable switch and the elapsed interval, and runs a sync when due. Failures from
|
||||||
|
/// the sync itself are reported on its result (and recorded in platform settings) rather than
|
||||||
|
/// thrown, so a bad run never tears down the loop.
|
||||||
|
/// </summary>
|
||||||
|
private async Task RunIfDueAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var settings = scope.ServiceProvider.GetRequiredService<IPlatformSettingsService>();
|
||||||
|
|
||||||
|
if (!await settings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled))
|
||||||
|
return; // master switch off
|
||||||
|
|
||||||
|
var intervalDays = Math.Max(1, await settings.GetIntAsync(
|
||||||
|
ColumbiaIntegrationConstants.SettingIntervalDays,
|
||||||
|
ColumbiaIntegrationConstants.DefaultSyncIntervalDays));
|
||||||
|
|
||||||
|
if (!IsDue(await settings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt), intervalDays))
|
||||||
|
return; // synced recently enough
|
||||||
|
|
||||||
|
var sync = scope.ServiceProvider.GetRequiredService<IColumbiaCatalogSyncService>();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Columbia scheduled sync starting (interval {Days}d).", intervalDays);
|
||||||
|
var result = await sync.RunSyncAsync(ct);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
_logger.LogInformation("Columbia scheduled sync complete: {Summary}", result.Summary);
|
||||||
|
else
|
||||||
|
_logger.LogWarning("Columbia scheduled sync did not succeed: {Error}", result.ErrorMessage);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Columbia scheduled sync threw unexpectedly.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sync is due when there is no recorded last-sync timestamp, or the configured number of
|
||||||
|
/// days has elapsed since it. An unparseable timestamp is treated as "due".
|
||||||
|
/// </summary>
|
||||||
|
private static bool IsDue(string? lastSyncedRaw, int intervalDays)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(lastSyncedRaw))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (!DateTime.TryParse(lastSyncedRaw, CultureInfo.InvariantCulture,
|
||||||
|
DateTimeStyles.RoundtripKind, out var lastSynced))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return DateTime.UtcNow - lastSynced.ToUniversalTime() >= TimeSpan.FromDays(intervalDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,8 @@ public class AccountsController : Controller
|
|||||||
// GET: /Accounts
|
// GET: /Accounts
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.GetAllAsync(false, a => a.ParentAccount);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId, false, a => a.ParentAccount);
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList());
|
var dtos = _mapper.Map<List<AccountListDto>>(accounts.OrderBy(a => a.AccountNumber).ToList());
|
||||||
|
|
||||||
@@ -65,6 +66,17 @@ public class AccountsController : Controller
|
|||||||
.OrderBy(g => (int)g.Key)
|
.OrderBy(g => (int)g.Key)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Default-account pickers (Revenue / COGS / Inventory) — see SaveDefaultAccounts.
|
||||||
|
await PopulateDefaultAccountViewDataAsync(companyId, accounts);
|
||||||
|
|
||||||
|
// GL health: trial-balance net. Debit-normal (Asset/COGS/Expense) minus credit-normal
|
||||||
|
// (Liability/Equity/Revenue) should net to ~0 for balanced books. A non-zero value flags
|
||||||
|
// drift or one-sided postings (often opening balances entered without an offsetting entry).
|
||||||
|
ViewBag.TrialBalanceNet = accounts.Sum(a =>
|
||||||
|
(a.AccountType == AccountType.Asset || a.AccountType == AccountType.CostOfGoods
|
||||||
|
|| a.AccountType == AccountType.Expense)
|
||||||
|
? a.CurrentBalance : -a.CurrentBalance);
|
||||||
|
|
||||||
return View(grouped);
|
return View(grouped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,18 +99,7 @@ public class AccountsController : Controller
|
|||||||
if (preSubType.HasValue)
|
if (preSubType.HasValue)
|
||||||
{
|
{
|
||||||
dto.AccountSubType = preSubType.Value;
|
dto.AccountSubType = preSubType.Value;
|
||||||
dto.AccountType = preSubType.Value switch
|
dto.AccountType = AccountClassification.TypeForSubType(preSubType.Value);
|
||||||
{
|
|
||||||
AccountSubType.Cash or AccountSubType.Checking or AccountSubType.Savings
|
|
||||||
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.FixedAsset
|
|
||||||
or AccountSubType.OtherCurrentAsset or AccountSubType.OtherAsset => AccountType.Asset,
|
|
||||||
AccountSubType.AccountsPayable or AccountSubType.CreditCard
|
|
||||||
or AccountSubType.OtherCurrentLiability or AccountSubType.LongTermLiability => AccountType.Liability,
|
|
||||||
AccountSubType.OwnersEquity or AccountSubType.RetainedEarnings => AccountType.Equity,
|
|
||||||
AccountSubType.Sales or AccountSubType.ServiceRevenue or AccountSubType.OtherIncome => AccountType.Revenue,
|
|
||||||
AccountSubType.CostOfGoodsSold => AccountType.CostOfGoods,
|
|
||||||
_ => AccountType.Expense
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
ViewBag.Inline = inline;
|
ViewBag.Inline = inline;
|
||||||
if (inline)
|
if (inline)
|
||||||
@@ -134,7 +135,7 @@ public class AccountsController : Controller
|
|||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
// Check for duplicate account number
|
// Check for duplicate account number
|
||||||
var existing = await _unitOfWork.Accounts.FindAsync(a => a.AccountNumber == dto.AccountNumber);
|
var existing = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.AccountNumber == dto.AccountNumber);
|
||||||
if (existing.Any())
|
if (existing.Any())
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
|
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
|
||||||
@@ -147,6 +148,9 @@ public class AccountsController : Controller
|
|||||||
var account = _mapper.Map<Account>(dto);
|
var account = _mapper.Map<Account>(dto);
|
||||||
account.CompanyId = currentUser!.CompanyId;
|
account.CompanyId = currentUser!.CompanyId;
|
||||||
account.CreatedBy = currentUser.Email;
|
account.CreatedBy = currentUser.Email;
|
||||||
|
// Derive the parent type from the chosen sub-type so the two can never disagree —
|
||||||
|
// a mismatch would post with the wrong debit/credit sign (sign keys off sub-type).
|
||||||
|
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||||||
|
|
||||||
await _unitOfWork.Accounts.AddAsync(account);
|
await _unitOfWork.Accounts.AddAsync(account);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -213,7 +217,7 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
// Check duplicate number (excluding self)
|
// Check duplicate number (excluding self)
|
||||||
var existing = await _unitOfWork.Accounts.FindAsync(
|
var existing = await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.AccountNumber == dto.AccountNumber && a.Id != id);
|
a => a.CompanyId == account.CompanyId && a.AccountNumber == dto.AccountNumber && a.Id != id);
|
||||||
if (existing.Any())
|
if (existing.Any())
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
|
ModelState.AddModelError(nameof(dto.AccountNumber), "An account with this number already exists.");
|
||||||
@@ -222,6 +226,9 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
_mapper.Map(dto, account);
|
_mapper.Map(dto, account);
|
||||||
|
// Keep type consistent with the chosen sub-type (see Create) so the sign convention,
|
||||||
|
// which keys off sub-type, can never be at odds with the displayed account type.
|
||||||
|
account.AccountType = AccountClassification.TypeForSubType(account.AccountSubType);
|
||||||
account.UpdatedAt = DateTime.UtcNow;
|
account.UpdatedAt = DateTime.UtcNow;
|
||||||
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
|
account.UpdatedBy = (await _userManager.GetUserAsync(User))?.Email;
|
||||||
|
|
||||||
@@ -321,20 +328,49 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One-time data repair for companies whose chart of accounts was imported from QuickBooks
|
/// Builds the Revenue / COGS / Inventory account dropdowns and the company's currently-selected
|
||||||
/// IIF files. QuickBooks IIF exports store credit-normal account opening balances as negative
|
/// default account IDs for the "Default Accounts" card on the Chart of Accounts page. Revenue and
|
||||||
/// numbers (e.g. Revenue accounts), but the application's convention is to store all opening
|
/// COGS are filtered by their top-level AccountType; the inventory-asset list shows all Asset
|
||||||
/// balances as positive amounts with the credit/debit nature implied by account type. This
|
/// accounts (Inventory sub-type first) so a company that classified its inventory account
|
||||||
/// action flips negative opening balances on Revenue, Liability, and Equity accounts to their
|
/// differently can still pick it. Reuses the already-loaded <paramref name="accounts"/> list.
|
||||||
/// absolute values. After running this, <see cref="RecalculateBalances"/> should be called to
|
|
||||||
/// propagate the corrected opening balances into <c>CurrentBalance</c>.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// POST: /Accounts/FixOpeningBalanceSigns
|
private async Task PopulateDefaultAccountViewDataAsync(int companyId, IEnumerable<Account> accounts)
|
||||||
// One-time fix: QB IIF imports store credit-normal accounts with negative opening balances.
|
{
|
||||||
// This flips them to positive so the chart of accounts displays correctly.
|
SelectListItem Item(Account a) => new($"{a.AccountNumber} – {a.Name}", a.Id.ToString());
|
||||||
|
|
||||||
|
ViewBag.DefaultRevenueAccounts = accounts
|
||||||
|
.Where(a => a.IsActive && a.AccountType == AccountType.Revenue)
|
||||||
|
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||||||
|
|
||||||
|
ViewBag.DefaultCogsAccounts = accounts
|
||||||
|
.Where(a => a.IsActive && a.AccountType == AccountType.CostOfGoods)
|
||||||
|
.OrderBy(a => a.AccountNumber).Select(Item).ToList();
|
||||||
|
|
||||||
|
ViewBag.DefaultInventoryAccounts = accounts
|
||||||
|
.Where(a => a.IsActive && a.AccountType == AccountType.Asset)
|
||||||
|
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
|
||||||
|
.ThenBy(a => a.AccountNumber).Select(Item).ToList();
|
||||||
|
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
ViewBag.DefaultRevenueAccountId = prefs?.DefaultRevenueAccountId;
|
||||||
|
ViewBag.DefaultCogsAccountId = prefs?.DefaultCogsAccountId;
|
||||||
|
ViewBag.DefaultInventoryAccountId = prefs?.DefaultInventoryAccountId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saves the company's default Revenue, COGS, and Inventory accounts to <c>CompanyPreferences</c>.
|
||||||
|
/// These are used as the fallback when an item leaves its account field blank: invoice lines fall
|
||||||
|
/// back to the default Revenue account (then 4000), and new inventory/catalog items are pre-filled
|
||||||
|
/// with the default COGS/Inventory accounts. Each submitted id is validated to belong to the
|
||||||
|
/// company and to be of the expected account type before it is stored; an invalid or cleared
|
||||||
|
/// selection saves as null. CompanyAdmin-only because it affects GL routing for the whole company.
|
||||||
|
/// </summary>
|
||||||
|
// POST: /Accounts/SaveDefaultAccounts
|
||||||
[HttpPost, ValidateAntiForgeryToken]
|
[HttpPost, ValidateAntiForgeryToken]
|
||||||
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
|
||||||
public async Task<IActionResult> FixOpeningBalanceSigns()
|
public async Task<IActionResult> SaveDefaultAccounts(
|
||||||
|
int? defaultRevenueAccountId, int? defaultCogsAccountId, int? defaultInventoryAccountId)
|
||||||
{
|
{
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId();
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
if (companyId == null)
|
if (companyId == null)
|
||||||
@@ -345,30 +381,37 @@ public class AccountsController : Controller
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
int fixed_ = 0;
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId.Value && !p.IsDeleted);
|
||||||
foreach (var acct in accounts)
|
if (prefs == null)
|
||||||
{
|
{
|
||||||
if (acct.OpeningBalance < 0 &&
|
TempData["Error"] = "Company preferences not found.";
|
||||||
acct.AccountType is Core.Enums.AccountType.Revenue
|
return RedirectToAction(nameof(Index));
|
||||||
or Core.Enums.AccountType.Liability
|
}
|
||||||
or Core.Enums.AccountType.Equity)
|
|
||||||
|
// Validate each pick belongs to this company, is active, and is of the right type.
|
||||||
|
// Explicit CompanyId predicate (defense in depth) alongside the global tenant filter.
|
||||||
|
async Task<int?> Validate(int? id, params AccountType[] allowed)
|
||||||
{
|
{
|
||||||
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
|
if (id == null) return null;
|
||||||
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
await _unitOfWork.Accounts.UpdateAsync(acct);
|
a => a.Id == id.Value && a.CompanyId == companyId.Value && a.IsActive);
|
||||||
fixed_++;
|
return acct != null && allowed.Contains(acct.AccountType) ? acct.Id : null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefs.DefaultRevenueAccountId = await Validate(defaultRevenueAccountId, AccountType.Revenue);
|
||||||
|
prefs.DefaultCogsAccountId = await Validate(defaultCogsAccountId, AccountType.CostOfGoods);
|
||||||
|
prefs.DefaultInventoryAccountId = await Validate(defaultInventoryAccountId, AccountType.Asset);
|
||||||
|
|
||||||
|
await _unitOfWork.CompanyPreferences.UpdateAsync(prefs);
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
TempData["Success"] = fixed_ > 0
|
|
||||||
? $"Fixed {fixed_} account(s) with negative opening balances. Run Recalculate Balances to update CurrentBalance."
|
TempData["Success"] = "Default accounts saved. New items and invoice lines will use these when no account is chosen.";
|
||||||
: "No accounts needed fixing — all opening balances already have the correct sign.";
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", companyId);
|
_logger.LogError(ex, "Error saving default accounts for company {CompanyId}", companyId);
|
||||||
TempData["Error"] = "An error occurred while fixing opening balances.";
|
TempData["Error"] = "An error occurred while saving the default accounts.";
|
||||||
}
|
}
|
||||||
|
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
@@ -439,7 +482,7 @@ public class AccountsController : Controller
|
|||||||
public async Task<IActionResult> YearEndClose()
|
public async Task<IActionResult> YearEndClose()
|
||||||
{
|
{
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => true, false, y => y.JournalEntry))
|
var history = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId, false, y => y.JournalEntry))
|
||||||
.OrderByDescending(y => y.ClosedYear)
|
.OrderByDescending(y => y.ClosedYear)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -464,7 +507,7 @@ public class AccountsController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Idempotency check
|
// Idempotency check
|
||||||
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.ClosedYear == year)).FirstOrDefault();
|
var existing = (await _unitOfWork.YearEndCloses.FindAsync(y => y.CompanyId == companyId && y.ClosedYear == year)).FirstOrDefault();
|
||||||
if (existing != null)
|
if (existing != null)
|
||||||
{
|
{
|
||||||
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
|
TempData["Error"] = $"{year} has already been closed (JE {existing.JournalEntryId}).";
|
||||||
@@ -472,7 +515,7 @@ public class AccountsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load all active accounts with balances
|
// Load all active accounts with balances
|
||||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.IsActive)).ToList();
|
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive)).ToList();
|
||||||
|
|
||||||
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
|
var revenueAccounts = accounts.Where(a => a.AccountType == AccountType.Revenue).ToList();
|
||||||
var expenseAccounts = accounts.Where(a =>
|
var expenseAccounts = accounts.Where(a =>
|
||||||
@@ -616,7 +659,8 @@ public class AccountsController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateDropdownsAsync(int? excludeId = null)
|
private async Task PopulateDropdownsAsync(int? excludeId = null)
|
||||||
{
|
{
|
||||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => excludeId == null || a.Id != excludeId.Value);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && (excludeId == null || a.Id != excludeId.Value));
|
||||||
|
|
||||||
ViewBag.ParentAccounts = allAccounts
|
ViewBag.ParentAccounts = allAccounts
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public class AiQuickQuoteController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||||
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
i.CompanyId == currentUser.CompanyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||||
if (powders.Any())
|
if (powders.Any())
|
||||||
avgPowderCost = powders.Average(p => p.UnitCost);
|
avgPowderCost = powders.Average(p => p.UnitCost);
|
||||||
}
|
}
|
||||||
@@ -180,7 +180,7 @@ public class AiQuickQuoteController : Controller
|
|||||||
var context = new CompanyAiContext { ProfileText = costs.AiContextProfile };
|
var context = new CompanyAiContext { ProfileText = costs.AiContextProfile };
|
||||||
|
|
||||||
var predictions = await _unitOfWork.AiItemPredictions.FindAsync(
|
var predictions = await _unitOfWork.AiItemPredictions.FindAsync(
|
||||||
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
||||||
|
|
||||||
context.AcceptedExamples = predictions
|
context.AcceptedExamples = predictions
|
||||||
.OrderByDescending(p => p.CreatedAt)
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
@@ -213,8 +213,9 @@ public class AiQuickQuoteController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||||
var inventory = await _unitOfWork.InventoryItems.FindAsync(
|
var inventory = await _unitOfWork.InventoryItems.FindAsync(
|
||||||
i => i.IsActive,
|
i => i.CompanyId == companyId && i.IsActive,
|
||||||
false,
|
false,
|
||||||
i => i.InventoryCategory);
|
i => i.InventoryCategory);
|
||||||
|
|
||||||
@@ -267,7 +268,7 @@ public class AiQuickQuoteController : Controller
|
|||||||
private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId)
|
private async Task<Customer> GetOrCreateWalkInCustomerAsync(int companyId)
|
||||||
{
|
{
|
||||||
var existing = (await _unitOfWork.Customers.FindAsync(
|
var existing = (await _unitOfWork.Customers.FindAsync(
|
||||||
c => c.CompanyName == "Walk-In / Phone" && c.IsActive))
|
c => c.CompanyId == companyId && c.CompanyName == "Walk-In / Phone" && c.IsActive))
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
if (existing != null) return existing;
|
if (existing != null) return existing;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using PowderCoating.Core.Entities;
|
|||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
|
using PowderCoating.Web.Helpers;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
@@ -73,6 +74,13 @@ public class BankReconciliationsController : Controller
|
|||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
// The account being reconciled must be a real money account (Asset/Liability).
|
||||||
|
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, model.AccountId, companyId))
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Select a valid bank, cash, or credit account to reconcile.";
|
||||||
|
return RedirectToAction(nameof(Create));
|
||||||
|
}
|
||||||
|
|
||||||
// Set beginning balance from last completed reconciliation for this account, or 0
|
// Set beginning balance from last completed reconciliation for this account, or 0
|
||||||
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
|
var lastCompleted = (await _unitOfWork.BankReconciliations.FindAsync(
|
||||||
br => br.CompanyId == companyId
|
br => br.CompanyId == companyId
|
||||||
@@ -365,11 +373,14 @@ public class BankReconciliationsController : Controller
|
|||||||
|
|
||||||
private async Task PopulateAccountDropdownAsync()
|
private async Task PopulateAccountDropdownAsync()
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
// Reconcilable accounts: any Asset (bank/cash) or Liability (credit card, line of
|
||||||
|
// credit) account. Filter by parent AccountType, not sub-type, so an account the
|
||||||
|
// company classified differently still shows up for reconciliation.
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive
|
a => a.CompanyId == companyId && a.IsActive
|
||||||
&& (a.AccountSubType == AccountSubType.Checking
|
&& (a.AccountType == AccountType.Asset
|
||||||
|| a.AccountSubType == AccountSubType.Savings
|
|| a.AccountType == AccountType.Liability));
|
||||||
|| a.AccountSubType == AccountSubType.Cash));
|
|
||||||
|
|
||||||
ViewBag.AccountSelectList = accounts
|
ViewBag.AccountSelectList = accounts
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
|
|||||||
@@ -202,14 +202,14 @@ public class BillsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
a => a.CompanyId == po.CompanyId && a.AccountSubType == AccountSubType.AccountsPayable);
|
||||||
|
|
||||||
// Vendor default expense account, fall back to first expense/COGS account
|
// Vendor default expense account, fall back to first expense/COGS account
|
||||||
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
|
int? defaultExpenseAccountId = po.Vendor?.DefaultExpenseAccountId;
|
||||||
if (!defaultExpenseAccountId.HasValue)
|
if (!defaultExpenseAccountId.HasValue)
|
||||||
{
|
{
|
||||||
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var fallbackAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
|
a => a.CompanyId == po.CompanyId && a.IsActive && (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods));
|
||||||
defaultExpenseAccountId = fallbackAccount?.Id;
|
defaultExpenseAccountId = fallbackAccount?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,8 +272,9 @@ public class BillsController : Controller
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Pre-fill AP account
|
// Pre-fill AP account
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var apAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountSubType == AccountSubType.AccountsPayable);
|
a => a.CompanyId == companyId && a.AccountSubType == AccountSubType.AccountsPayable);
|
||||||
dto.APAccountId = apAccount?.Id ?? 0;
|
dto.APAccountId = apAccount?.Id ?? 0;
|
||||||
|
|
||||||
// Pre-fill default expense account for vendor
|
// Pre-fill default expense account for vendor
|
||||||
@@ -339,6 +340,16 @@ public class BillsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate the pay-from account before entering the transaction so an invalid
|
||||||
|
// selection rejects the whole request rather than saving a bill with no payment.
|
||||||
|
if (payNow && paymentMethod.HasValue && bankAccountId.HasValue && currentUser != null
|
||||||
|
&& !await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, bankAccountId, currentUser.CompanyId))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, "Choose a valid bank or credit account to record the payment.");
|
||||||
|
await PopulateDropdownsAsync();
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
|
||||||
Bill? bill = null;
|
Bill? bill = null;
|
||||||
|
|
||||||
// Bill entity, PO back-reference, and optional immediate payment all commit
|
// Bill entity, PO back-reference, and optional immediate payment all commit
|
||||||
@@ -451,11 +462,12 @@ public class BillsController : Controller
|
|||||||
var dto = _mapper.Map<BillDto>(bill);
|
var dto = _mapper.Map<BillDto>(bill);
|
||||||
|
|
||||||
// Payment form defaults
|
// Payment form defaults
|
||||||
|
// Payment sources: filter by parent AccountType (Asset or Liability), not sub-type,
|
||||||
|
// so accounts a company classified under a different sub-type still appear.
|
||||||
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
|
var bankAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.AccountSubType == AccountSubType.Cash ||
|
a => a.CompanyId == bill.CompanyId &&
|
||||||
a.AccountSubType == AccountSubType.Checking ||
|
(a.AccountType == AccountType.Asset ||
|
||||||
a.AccountSubType == AccountSubType.Savings ||
|
a.AccountType == AccountType.Liability)))
|
||||||
a.AccountSubType == AccountSubType.CreditCard))
|
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -716,6 +728,14 @@ public class BillsController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The pay-from account must be a real money account (Asset/Liability) — defense in depth
|
||||||
|
// against a tampered or stale selection before we post to it.
|
||||||
|
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, bill.CompanyId))
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Select a valid bank or credit account to pay from.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||||
|
}
|
||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
var payment = _mapper.Map<BillPayment>(dto);
|
var payment = _mapper.Map<BillPayment>(dto);
|
||||||
@@ -841,6 +861,13 @@ public class BillsController : Controller
|
|||||||
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
|
var payment = await _unitOfWork.BillPayments.GetByIdAsync(dto.PaymentId);
|
||||||
if (payment == null) return NotFound();
|
if (payment == null) return NotFound();
|
||||||
|
|
||||||
|
// Reject an invalid new pay-from account before we move the balance to it.
|
||||||
|
if (!await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, dto.BankAccountId, payment.CompanyId))
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Select a valid bank or credit account.";
|
||||||
|
return RedirectToAction(nameof(Details), new { id = dto.BillId });
|
||||||
|
}
|
||||||
|
|
||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
// If the bank account changed, reverse the old balance entry and apply the new one
|
// If the bank account changed, reverse the old balance entry and apply the new one
|
||||||
@@ -1076,7 +1103,8 @@ public class BillsController : Controller
|
|||||||
return Json(new { success = false, error = "File must be under 10 MB." });
|
return Json(new { success = false, error = "File must be under 10 MB." });
|
||||||
|
|
||||||
// Load expense accounts for matching
|
// Load expense accounts for matching
|
||||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||||
var expenseAccounts = allAccounts
|
var expenseAccounts = allAccounts
|
||||||
.Where(a => a.AccountType == AccountType.Expense ||
|
.Where(a => a.AccountType == AccountType.Expense ||
|
||||||
a.AccountType == AccountType.CostOfGoods ||
|
a.AccountType == AccountType.CostOfGoods ||
|
||||||
@@ -1096,7 +1124,6 @@ public class BillsController : Controller
|
|||||||
var imageBytes = ms.ToArray();
|
var imageBytes = ms.ToArray();
|
||||||
|
|
||||||
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
|
var result = await _accountingAi.ScanReceiptAsync(imageBytes, receiptImage.ContentType, expenseAccounts);
|
||||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
|
||||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||||
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
|
await _usageLogger.LogAsync(companyId, userId, AppConstants.AiFeatures.ReceiptScan, inputLength: (int)receiptImage.Length);
|
||||||
return Json(result);
|
return Json(result);
|
||||||
@@ -1123,7 +1150,8 @@ public class BillsController : Controller
|
|||||||
// Load expense accounts if not supplied
|
// Load expense accounts if not supplied
|
||||||
if (!request.AvailableAccounts.Any())
|
if (!request.AvailableAccounts.Any())
|
||||||
{
|
{
|
||||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||||
request.AvailableAccounts = allAccounts
|
request.AvailableAccounts = allAccounts
|
||||||
.Where(a => a.AccountType == AccountType.Expense ||
|
.Where(a => a.AccountType == AccountType.Expense ||
|
||||||
a.AccountType == AccountType.CostOfGoods ||
|
a.AccountType == AccountType.CostOfGoods ||
|
||||||
@@ -1170,7 +1198,7 @@ public class BillsController : Controller
|
|||||||
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||||
var cutoff = DateTime.Today.AddMonths(-12);
|
var cutoff = DateTime.Today.AddMonths(-12);
|
||||||
|
|
||||||
var bills = (await _unitOfWork.Bills.GetAllAsync(false, b => b.Vendor))
|
var bills = (await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId, false, b => b.Vendor))
|
||||||
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
.Where(b => b.Status != BillStatus.Voided && b.BillDate >= cutoff)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ public class BudgetsController : Controller
|
|||||||
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
|
/// <summary>Lists all budgets for the current company ordered by fiscal year descending.</summary>
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
var budgets = (await _unitOfWork.Budgets.FindAsync(b => true, false, b => b.Lines))
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var budgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId, false, b => b.Lines))
|
||||||
.OrderByDescending(b => b.FiscalYear)
|
.OrderByDescending(b => b.FiscalYear)
|
||||||
.ThenBy(b => b.Name)
|
.ThenBy(b => b.Name)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -246,15 +247,16 @@ public class BudgetsController : Controller
|
|||||||
|
|
||||||
private async Task<List<Account>> GetBudgetableAccountsAsync()
|
private async Task<List<Account>> GetBudgetableAccountsAsync()
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Revenue || a.AccountType == AccountType.Expense));
|
||||||
return accounts.OrderBy(a => a.AccountNumber).ToList();
|
return accounts.OrderBy(a => a.AccountNumber).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
|
private async Task ClearDefaultFlagAsync(int companyId, int fiscalYear, int? excludeId)
|
||||||
{
|
{
|
||||||
var others = await _unitOfWork.Budgets.FindAsync(
|
var others = await _unitOfWork.Budgets.FindAsync(
|
||||||
b => b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
b => b.CompanyId == companyId && b.IsDefault && b.FiscalYear == fiscalYear && b.Id != (excludeId ?? 0));
|
||||||
foreach (var b in others)
|
foreach (var b in others)
|
||||||
{
|
{
|
||||||
b.IsDefault = false;
|
b.IsDefault = false;
|
||||||
|
|||||||
@@ -208,10 +208,16 @@ namespace PowderCoating.Web.Controllers
|
|||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
ViewBag.AreaUnit = _measurementService.GetAreaUnitLabel(useMetric);
|
||||||
|
|
||||||
|
// Pre-fill the GL account dropdowns from the company's configured defaults.
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
|
||||||
var model = new CreateCatalogItemDto
|
var model = new CreateCatalogItemDto
|
||||||
{
|
{
|
||||||
CategoryId = categoryId ?? 0,
|
CategoryId = categoryId ?? 0,
|
||||||
DisplayOrder = 0
|
DisplayOrder = 0,
|
||||||
|
RevenueAccountId = prefs?.DefaultRevenueAccountId,
|
||||||
|
CogsAccountId = prefs?.DefaultCogsAccountId
|
||||||
};
|
};
|
||||||
|
|
||||||
return View(model);
|
return View(model);
|
||||||
@@ -494,7 +500,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CategoryId == categoryId && i.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var items = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId && i.CategoryId == categoryId && i.IsActive);
|
||||||
|
|
||||||
var itemDtos = items
|
var itemDtos = items
|
||||||
.OrderBy(i => i.DisplayOrder)
|
.OrderBy(i => i.DisplayOrder)
|
||||||
@@ -535,8 +542,9 @@ namespace PowderCoating.Web.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var items = await _unitOfWork.CatalogItems.FindAsync(
|
var items = await _unitOfWork.CatalogItems.FindAsync(
|
||||||
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
|
i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||||
|
|
||||||
var result = items
|
var result = items
|
||||||
.OrderBy(i => i.Category.Name)
|
.OrderBy(i => i.Category.Name)
|
||||||
@@ -670,7 +678,8 @@ namespace PowderCoating.Web.Controllers
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||||
|
|
||||||
var revenueAccounts = accounts
|
var revenueAccounts = accounts
|
||||||
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
|
.Where(a => a.AccountType == PowderCoating.Core.Enums.AccountType.Revenue)
|
||||||
@@ -686,6 +695,13 @@ namespace PowderCoating.Web.Controllers
|
|||||||
|
|
||||||
ViewBag.RevenueAccounts = revenueAccounts;
|
ViewBag.RevenueAccounts = revenueAccounts;
|
||||||
ViewBag.CogsAccounts = cogsAccounts;
|
ViewBag.CogsAccounts = cogsAccounts;
|
||||||
|
|
||||||
|
// Whether the company has configured default accounts — the views use this to label the
|
||||||
|
// blank dropdown option "(Default …)" vs "(None)".
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
ViewBag.HasDefaultRevenueAccount = prefs?.DefaultRevenueAccountId != null;
|
||||||
|
ViewBag.HasDefaultCogsAccount = prefs?.DefaultCogsAccountId != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -898,7 +914,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
|
|
||||||
// Get all active catalog items with their categories
|
// Get all active catalog items with their categories
|
||||||
var items = await _unitOfWork.CatalogItems.FindAsync(
|
var items = await _unitOfWork.CatalogItems.FindAsync(
|
||||||
ci => ci.IsActive,
|
ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive,
|
||||||
false,
|
false,
|
||||||
ci => ci.Category
|
ci => ci.Category
|
||||||
);
|
);
|
||||||
@@ -953,7 +969,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
r => r.CompanyId == currentUser.CompanyId);
|
r => r.CompanyId == currentUser.CompanyId);
|
||||||
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
|
var report = existing.OrderByDescending(r => r.RunAt).FirstOrDefault();
|
||||||
|
|
||||||
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.IsActive && ci.DefaultPrice > 0);
|
var pricedItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0);
|
||||||
ViewBag.ActiveItemCount = pricedItems.Count();
|
ViewBag.ActiveItemCount = pricedItems.Count();
|
||||||
|
|
||||||
if (report != null)
|
if (report != null)
|
||||||
@@ -1037,7 +1053,7 @@ namespace PowderCoating.Web.Controllers
|
|||||||
// Load active catalog items with a real price — skip $0 items (placeholders,
|
// Load active catalog items with a real price — skip $0 items (placeholders,
|
||||||
// category headers, etc.) since there's no pricing to evaluate.
|
// category headers, etc.) since there's no pricing to evaluate.
|
||||||
var items = (await _unitOfWork.CatalogItems.FindAsync(
|
var items = (await _unitOfWork.CatalogItems.FindAsync(
|
||||||
ci => ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
|
ci => ci.CompanyId == currentUser.CompanyId && ci.IsActive && ci.DefaultPrice > 0, false, ci => ci.Category)).ToList();
|
||||||
|
|
||||||
if (items.Count == 0)
|
if (items.Count == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -754,6 +754,69 @@ public class CompaniesController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One-time data repair for a company whose chart of accounts was imported from QuickBooks
|
||||||
|
/// IIF files. QuickBooks stores credit-normal account opening balances as negative numbers
|
||||||
|
/// (e.g. Revenue, Liability, Equity), but this app's convention is positive opening balances
|
||||||
|
/// with the debit/credit nature implied by account type. This flips negative opening balances
|
||||||
|
/// on Revenue/Liability/Equity accounts to their absolute values so the chart of accounts
|
||||||
|
/// reads correctly. Afterward, Recalculate Balances (on the Chart of Accounts page) should be
|
||||||
|
/// run to propagate the corrected opening balances into CurrentBalance.
|
||||||
|
/// <para>
|
||||||
|
/// This is a SuperAdmin-only platform tool — it operates on the target company identified by
|
||||||
|
/// <paramref name="id"/> (not the caller's tenant), so it uses <c>ignoreQueryFilters</c> to
|
||||||
|
/// reach across the multi-tenancy boundary. It was deliberately moved here from the company
|
||||||
|
/// Chart of Accounts page so normal company admins can't see or trigger it.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
// POST: Companies/FixOpeningBalanceSigns/5
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> FixOpeningBalanceSigns(int id)
|
||||||
|
{
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
|
||||||
|
if (company == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Company not found.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Explicit CompanyId predicate + ignoreQueryFilters: SuperAdmin acts on another
|
||||||
|
// tenant, so the global multi-tenancy filter must be bypassed but scoping kept tight.
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
|
a => a.CompanyId == id && a.IsActive, ignoreQueryFilters: true);
|
||||||
|
|
||||||
|
int fixedCount = 0;
|
||||||
|
foreach (var acct in accounts)
|
||||||
|
{
|
||||||
|
if (acct.OpeningBalance < 0 &&
|
||||||
|
acct.AccountType is Core.Enums.AccountType.Revenue
|
||||||
|
or Core.Enums.AccountType.Liability
|
||||||
|
or Core.Enums.AccountType.Equity)
|
||||||
|
{
|
||||||
|
acct.OpeningBalance = Math.Abs(acct.OpeningBalance);
|
||||||
|
acct.CurrentBalance = Math.Abs(acct.CurrentBalance);
|
||||||
|
await _unitOfWork.Accounts.UpdateAsync(acct);
|
||||||
|
fixedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
TempData[fixedCount > 0 ? "Success" : "Info"] = fixedCount > 0
|
||||||
|
? $"Fixed {fixedCount} account(s) with negative opening balances for '{company.CompanyName}'. Run Recalculate Balances on the company's Chart of Accounts to update CurrentBalance."
|
||||||
|
: $"No accounts needed fixing for '{company.CompanyName}' — all opening balances already have the correct sign.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error fixing opening balance signs for company {CompanyId}", id);
|
||||||
|
TempData["Error"] = "An error occurred while fixing opening balances.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Renders the form for adding an additional CompanyAdmin user to an existing company.
|
/// Renders the form for adding an additional CompanyAdmin user to an existing company.
|
||||||
/// Used when a company needs more than one admin or when the original admin's account must
|
/// Used when a company needs more than one admin or when the original admin's account must
|
||||||
|
|||||||
@@ -990,7 +990,7 @@ public class CompanySettingsController : Controller
|
|||||||
// Add job counts
|
// Add job counts
|
||||||
foreach (var dto in dtos)
|
foreach (var dto in dtos)
|
||||||
{
|
{
|
||||||
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id);
|
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobStatusId == dto.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
@@ -1023,7 +1023,7 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// Check if status code already exists for this company
|
// Check if status code already exists for this company
|
||||||
var exists = await _unitOfWork.JobStatusLookups
|
var exists = await _unitOfWork.JobStatusLookups
|
||||||
.AnyAsync(s => s.StatusCode == dto.StatusCode);
|
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
|
||||||
if (exists)
|
if (exists)
|
||||||
return Json(new { success = false, message = "Status code already exists" });
|
return Json(new { success = false, message = "Status code already exists" });
|
||||||
|
|
||||||
@@ -1100,7 +1100,7 @@ public class CompanySettingsController : Controller
|
|||||||
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
||||||
|
|
||||||
// Check if status is in use
|
// Check if status is in use
|
||||||
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id);
|
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == status.CompanyId && j.JobStatusId == id);
|
||||||
if (inUse)
|
if (inUse)
|
||||||
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
||||||
|
|
||||||
@@ -1184,7 +1184,7 @@ public class CompanySettingsController : Controller
|
|||||||
// Add job counts
|
// Add job counts
|
||||||
foreach (var dto in dtos)
|
foreach (var dto in dtos)
|
||||||
{
|
{
|
||||||
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id);
|
dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobPriorityId == dto.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
@@ -1216,7 +1216,7 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// Check if priority code already exists for this company
|
// Check if priority code already exists for this company
|
||||||
var exists = await _unitOfWork.JobPriorityLookups
|
var exists = await _unitOfWork.JobPriorityLookups
|
||||||
.AnyAsync(p => p.PriorityCode == dto.PriorityCode);
|
.AnyAsync(p => p.CompanyId == companyId.Value && p.PriorityCode == dto.PriorityCode);
|
||||||
if (exists)
|
if (exists)
|
||||||
return Json(new { success = false, message = "Priority code already exists" });
|
return Json(new { success = false, message = "Priority code already exists" });
|
||||||
|
|
||||||
@@ -1290,7 +1290,7 @@ public class CompanySettingsController : Controller
|
|||||||
return Json(new { success = false, message = "Cannot delete system-defined priority" });
|
return Json(new { success = false, message = "Cannot delete system-defined priority" });
|
||||||
|
|
||||||
// Check if priority is in use
|
// Check if priority is in use
|
||||||
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id);
|
var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == priority.CompanyId && j.JobPriorityId == id);
|
||||||
if (inUse)
|
if (inUse)
|
||||||
return Json(new { success = false, message = "Priority is in use and cannot be deleted" });
|
return Json(new { success = false, message = "Priority is in use and cannot be deleted" });
|
||||||
|
|
||||||
@@ -1370,7 +1370,7 @@ public class CompanySettingsController : Controller
|
|||||||
// Add quote counts
|
// Add quote counts
|
||||||
foreach (var dto in dtos)
|
foreach (var dto in dtos)
|
||||||
{
|
{
|
||||||
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id);
|
dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId && q.QuoteStatusId == dto.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
@@ -1403,7 +1403,7 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// Check if status code already exists for this company
|
// Check if status code already exists for this company
|
||||||
var exists = await _unitOfWork.QuoteStatusLookups
|
var exists = await _unitOfWork.QuoteStatusLookups
|
||||||
.AnyAsync(s => s.StatusCode == dto.StatusCode);
|
.AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode);
|
||||||
if (exists)
|
if (exists)
|
||||||
return Json(new { success = false, message = "Status code already exists" });
|
return Json(new { success = false, message = "Status code already exists" });
|
||||||
|
|
||||||
@@ -1411,7 +1411,7 @@ public class CompanySettingsController : Controller
|
|||||||
if (dto.IsApprovedStatus)
|
if (dto.IsApprovedStatus)
|
||||||
{
|
{
|
||||||
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
||||||
.AnyAsync(s => s.IsApprovedStatus);
|
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsApprovedStatus);
|
||||||
if (hasApproved)
|
if (hasApproved)
|
||||||
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
||||||
}
|
}
|
||||||
@@ -1419,7 +1419,7 @@ public class CompanySettingsController : Controller
|
|||||||
if (dto.IsConvertedStatus)
|
if (dto.IsConvertedStatus)
|
||||||
{
|
{
|
||||||
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
||||||
.AnyAsync(s => s.IsConvertedStatus);
|
.AnyAsync(s => s.CompanyId == companyId.Value && s.IsConvertedStatus);
|
||||||
if (hasConverted)
|
if (hasConverted)
|
||||||
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
||||||
}
|
}
|
||||||
@@ -1466,7 +1466,7 @@ public class CompanySettingsController : Controller
|
|||||||
if (dto.IsApprovedStatus && !status.IsApprovedStatus)
|
if (dto.IsApprovedStatus && !status.IsApprovedStatus)
|
||||||
{
|
{
|
||||||
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
var hasApproved = await _unitOfWork.QuoteStatusLookups
|
||||||
.AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus);
|
.AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsApprovedStatus);
|
||||||
if (hasApproved)
|
if (hasApproved)
|
||||||
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
return Json(new { success = false, message = "Only one status can be marked as 'Approved'" });
|
||||||
}
|
}
|
||||||
@@ -1474,7 +1474,7 @@ public class CompanySettingsController : Controller
|
|||||||
if (dto.IsConvertedStatus && !status.IsConvertedStatus)
|
if (dto.IsConvertedStatus && !status.IsConvertedStatus)
|
||||||
{
|
{
|
||||||
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
var hasConverted = await _unitOfWork.QuoteStatusLookups
|
||||||
.AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus);
|
.AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsConvertedStatus);
|
||||||
if (hasConverted)
|
if (hasConverted)
|
||||||
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
return Json(new { success = false, message = "Only one status can be marked as 'Converted'" });
|
||||||
}
|
}
|
||||||
@@ -1512,7 +1512,7 @@ public class CompanySettingsController : Controller
|
|||||||
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
return Json(new { success = false, message = "Cannot delete system-defined status" });
|
||||||
|
|
||||||
// Check if status is in use
|
// Check if status is in use
|
||||||
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id);
|
var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.CompanyId == status.CompanyId && q.QuoteStatusId == id);
|
||||||
if (inUse)
|
if (inUse)
|
||||||
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
return Json(new { success = false, message = "Status is in use and cannot be deleted" });
|
||||||
|
|
||||||
@@ -1909,7 +1909,7 @@ public class CompanySettingsController : Controller
|
|||||||
// Add appointment counts
|
// Add appointment counts
|
||||||
foreach (var dto in dtos)
|
foreach (var dto in dtos)
|
||||||
{
|
{
|
||||||
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id);
|
dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.CompanyId == companyId && a.AppointmentTypeId == dto.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
@@ -1941,7 +1941,7 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// Check if type code already exists for this company
|
// Check if type code already exists for this company
|
||||||
var exists = await _unitOfWork.AppointmentTypeLookups
|
var exists = await _unitOfWork.AppointmentTypeLookups
|
||||||
.AnyAsync(t => t.TypeCode == dto.TypeCode);
|
.AnyAsync(t => t.CompanyId == companyId.Value && t.TypeCode == dto.TypeCode);
|
||||||
if (exists)
|
if (exists)
|
||||||
return Json(new { success = false, message = "Type code already exists" });
|
return Json(new { success = false, message = "Type code already exists" });
|
||||||
|
|
||||||
@@ -2015,7 +2015,7 @@ public class CompanySettingsController : Controller
|
|||||||
return Json(new { success = false, message = "Cannot delete system-defined type" });
|
return Json(new { success = false, message = "Cannot delete system-defined type" });
|
||||||
|
|
||||||
// Check if type is in use
|
// Check if type is in use
|
||||||
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id);
|
var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.CompanyId == type.CompanyId && a.AppointmentTypeId == id);
|
||||||
if (inUse)
|
if (inUse)
|
||||||
return Json(new { success = false, message = "Type is in use and cannot be deleted" });
|
return Json(new { success = false, message = "Type is in use and cannot be deleted" });
|
||||||
|
|
||||||
@@ -2095,7 +2095,7 @@ public class CompanySettingsController : Controller
|
|||||||
// Add item counts
|
// Add item counts
|
||||||
foreach (var dto in dtos)
|
foreach (var dto in dtos)
|
||||||
{
|
{
|
||||||
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id);
|
dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.CompanyId == companyId && i.InventoryCategoryId == dto.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Json(dtos);
|
return Json(dtos);
|
||||||
@@ -2127,7 +2127,7 @@ public class CompanySettingsController : Controller
|
|||||||
|
|
||||||
// Check if category code already exists for this company
|
// Check if category code already exists for this company
|
||||||
var exists = await _unitOfWork.InventoryCategoryLookups
|
var exists = await _unitOfWork.InventoryCategoryLookups
|
||||||
.AnyAsync(c => c.CategoryCode == dto.CategoryCode);
|
.AnyAsync(c => c.CompanyId == companyId.Value && c.CategoryCode == dto.CategoryCode);
|
||||||
if (exists)
|
if (exists)
|
||||||
return Json(new { success = false, message = "Category code already exists" });
|
return Json(new { success = false, message = "Category code already exists" });
|
||||||
|
|
||||||
@@ -2193,7 +2193,7 @@ public class CompanySettingsController : Controller
|
|||||||
return Json(new { success = false, message = "Category not found" });
|
return Json(new { success = false, message = "Category not found" });
|
||||||
|
|
||||||
// Check if category is in use
|
// Check if category is in use
|
||||||
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id);
|
var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.CompanyId == category.CompanyId && i.InventoryCategoryId == id);
|
||||||
if (inUse)
|
if (inUse)
|
||||||
return Json(new { success = false, message = "Category is in use and cannot be deleted" });
|
return Json(new { success = false, message = "Category is in use and cannot be deleted" });
|
||||||
|
|
||||||
@@ -2404,7 +2404,7 @@ public class CompanySettingsController : Controller
|
|||||||
return Json(new { success = false, message = "Oven not found." });
|
return Json(new { success = false, message = "Oven not found." });
|
||||||
|
|
||||||
// Check if any quotes reference this oven
|
// Check if any quotes reference this oven
|
||||||
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id);
|
var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value && q.OvenCostId == id);
|
||||||
if (usageCount > 0)
|
if (usageCount > 0)
|
||||||
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });
|
return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." });
|
||||||
|
|
||||||
|
|||||||
@@ -47,8 +47,9 @@ public class CreditMemosController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Index(string? status, string? search)
|
public async Task<IActionResult> Index(string? status, string? search)
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var memos = await _unitOfWork.CreditMemos.FindAsync(
|
var memos = await _unitOfWork.CreditMemos.FindAsync(
|
||||||
m => true, false,
|
m => m.CompanyId == companyId, false,
|
||||||
m => m.Customer);
|
m => m.Customer);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(search))
|
if (!string.IsNullOrWhiteSpace(search))
|
||||||
@@ -177,6 +178,13 @@ public class CreditMemosController : Controller
|
|||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
|
||||||
|
// / CR Customer Credits (2350). Relieved when the memo is applied to an invoice.
|
||||||
|
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == customer.CompanyId && a.AccountNumber == "4950" && a.IsActive);
|
||||||
|
var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == customer.CompanyId && a.AccountNumber == "2350" && a.IsActive);
|
||||||
|
await _accountBalanceService.DebitAsync(discountAcct?.Id, vm.Amount);
|
||||||
|
await _accountBalanceService.CreditAsync(customerCreditsAcct?.Id, vm.Amount);
|
||||||
|
|
||||||
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
|
TempData["Success"] = $"Credit memo {memoNumber} for {vm.Amount:C} issued to {DisplayName(customer)}.";
|
||||||
return RedirectToAction(nameof(Details), new { id = memo.Id });
|
return RedirectToAction(nameof(Details), new { id = memo.Id });
|
||||||
}
|
}
|
||||||
@@ -252,18 +260,14 @@ public class CreditMemosController : Controller
|
|||||||
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
await _unitOfWork.Invoices.UpdateAsync(invoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
// GL: DR 4950 Sales Discounts (contra-revenue) / CR AR.
|
// GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
|
||||||
// The dynamic report computation attributes credit memo applications to both
|
// The contra-revenue (Sales Discounts) was recognized when the credit was issued.
|
||||||
// accounts already; this call keeps Account.CurrentBalance in sync for
|
// Keeps Account.CurrentBalance in sync for RecalculateAllAsync and direct readers.
|
||||||
// RecalculateAllAsync and any tools that read it directly.
|
|
||||||
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var arAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
a => a.CompanyId == invoice.CompanyId && a.AccountSubType == AccountSubType.AccountsReceivable && a.IsActive);
|
||||||
var discountAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var customerCreditsAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountNumber == "4950" && a.IsActive)
|
a => a.CompanyId == invoice.CompanyId && a.AccountNumber == "2350" && a.IsActive);
|
||||||
?? await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
await _accountBalanceService.DebitAsync(customerCreditsAcct?.Id, applyAmount);
|
||||||
a => a.AccountType == AccountType.Revenue && a.IsActive
|
|
||||||
&& a.Name.ToLower().Contains("discount"));
|
|
||||||
await _accountBalanceService.DebitAsync(discountAcct?.Id, applyAmount);
|
|
||||||
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
|
await _accountBalanceService.CreditAsync(arAcct?.Id, applyAmount);
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -308,6 +312,15 @@ public class CreditMemosController : Controller
|
|||||||
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts.
|
||||||
|
if (remaining > 0)
|
||||||
|
{
|
||||||
|
var ccAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == memo.CompanyId && a.AccountNumber == "2350" && a.IsActive);
|
||||||
|
var sdAcct = await _unitOfWork.Accounts.FirstOrDefaultAsync(a => a.CompanyId == memo.CompanyId && a.AccountNumber == "4950" && a.IsActive);
|
||||||
|
await _accountBalanceService.DebitAsync(ccAcct?.Id, remaining);
|
||||||
|
await _accountBalanceService.CreditAsync(sdAcct?.Id, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
|
TempData["Success"] = "Credit memo voided. Unapplied balance reversed from customer credit.";
|
||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
|
|||||||
@@ -1411,7 +1411,8 @@ public class CustomersController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulatePricingTiersAsync()
|
private async Task PopulatePricingTiersAsync()
|
||||||
{
|
{
|
||||||
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.IsActive);
|
||||||
ViewBag.PricingTiers = tiers
|
ViewBag.PricingTiers = tiers
|
||||||
.OrderBy(t => t.TierName)
|
.OrderBy(t => t.TierName)
|
||||||
.Select(t => new SelectListItem
|
.Select(t => new SelectListItem
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class DashboardController : Controller
|
|||||||
private readonly ICompanyConfigHealthService _configHealth;
|
private readonly ICompanyConfigHealthService _configHealth;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
private readonly ISubscriptionService _subscriptionService;
|
private readonly ISubscriptionService _subscriptionService;
|
||||||
|
private readonly IInventoryAiLookupService _aiLookupService;
|
||||||
|
|
||||||
public DashboardController(
|
public DashboardController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
@@ -33,7 +34,8 @@ public class DashboardController : Controller
|
|||||||
ITenantContext tenantContext,
|
ITenantContext tenantContext,
|
||||||
ICompanyConfigHealthService configHealth,
|
ICompanyConfigHealthService configHealth,
|
||||||
UserManager<ApplicationUser> userManager,
|
UserManager<ApplicationUser> userManager,
|
||||||
ISubscriptionService subscriptionService)
|
ISubscriptionService subscriptionService,
|
||||||
|
IInventoryAiLookupService aiLookupService)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -42,6 +44,7 @@ public class DashboardController : Controller
|
|||||||
_configHealth = configHealth;
|
_configHealth = configHealth;
|
||||||
_userManager = userManager;
|
_userManager = userManager;
|
||||||
_subscriptionService = subscriptionService;
|
_subscriptionService = subscriptionService;
|
||||||
|
_aiLookupService = aiLookupService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -496,7 +499,7 @@ public class DashboardController : Controller
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
// These share the same scoped DbContext so must run sequentially
|
// These share the same scoped DbContext so must run sequentially
|
||||||
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true);
|
var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(h => h.CompanyId == companyId);
|
||||||
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
|
// ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible
|
||||||
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
|
var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync(
|
||||||
j => j.CompanyId == companyId && j.UpdatedAt != null,
|
j => j.CompanyId == companyId && j.UpdatedAt != null,
|
||||||
@@ -765,27 +768,147 @@ public class DashboardController : Controller
|
|||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Enrich from the platform powder catalog so the new inventory record carries the full
|
||||||
|
// spec/doc set (cure schedule, SDS/TDS, sample image, color families) rather than just
|
||||||
|
// the color code/name carried on the quote. Match by the catalog SKU (stored as the
|
||||||
|
// coat's colorCode), preferring the same manufacturer; fall back to color name.
|
||||||
|
await EnrichInventoryFromCatalogAsync(inventoryItem, colorCode, colorName, manufacturer);
|
||||||
|
|
||||||
|
var linkedCount = await FinalizeReceivedPowderAsync(
|
||||||
|
coat, inventoryItem, lbsReceived, companyId, colorCode, colorName, primaryVendorId,
|
||||||
|
jobItem?.Job?.JobNumber);
|
||||||
|
|
||||||
|
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId);
|
||||||
|
return Json(new { success = false, message = "An error occurred while saving." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the platform powder catalog row for an inventory/coat identity: by catalog SKU
|
||||||
|
/// (stored as the coat's color code), preferring the same manufacturer, then by color name.
|
||||||
|
/// Returns null when no match is found.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<PowderCatalogItem?> FindCatalogByIdentityAsync(
|
||||||
|
string? colorCode, string? colorName, string? manufacturer)
|
||||||
|
{
|
||||||
|
var code = colorCode?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(code))
|
||||||
|
{
|
||||||
|
var codeLower = code.ToLower();
|
||||||
|
var hits = (await _unitOfWork.PowderCatalog.FindAsync(p => p.Sku.ToLower() == codeLower)).ToList();
|
||||||
|
var mfr = manufacturer?.Trim().ToLower();
|
||||||
|
var match = (!string.IsNullOrWhiteSpace(mfr)
|
||||||
|
? hits.FirstOrDefault(p => p.VendorName.ToLower().Contains(mfr))
|
||||||
|
: null)
|
||||||
|
?? hits.FirstOrDefault();
|
||||||
|
if (match != null)
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(colorName))
|
||||||
|
{
|
||||||
|
var nameLower = colorName.Trim().ToLower();
|
||||||
|
return (await _unitOfWork.PowderCatalog.FindAsync(p => p.ColorName.ToLower() == nameLower))
|
||||||
|
.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Copies catalog spec/document fields onto an inventory item — cure schedule, coverage,
|
||||||
|
/// specific gravity, transfer efficiency, SDS/TDS links, sample image, color families, product
|
||||||
|
/// page — and links <see cref="InventoryItem.PowderCatalogItemId"/>. Only fills gaps, so any
|
||||||
|
/// value already set (e.g. entered on the receive form) is preserved.
|
||||||
|
/// </summary>
|
||||||
|
private static void ApplyCatalogToInventory(InventoryItem item, PowderCatalogItem catalog)
|
||||||
|
{
|
||||||
|
item.PowderCatalogItemId = catalog.Id;
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(item.ManufacturerPartNumber)) item.ManufacturerPartNumber = catalog.Sku;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Manufacturer)) item.Manufacturer = catalog.VendorName;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.ColorName)) item.ColorName = catalog.ColorName;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.Finish)) item.Finish = catalog.Finish;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.ColorFamilies)) item.ColorFamilies = catalog.ColorFamilies;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.ImageUrl)) item.ImageUrl = catalog.ImageUrl;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.SdsUrl)) item.SdsUrl = catalog.SdsUrl;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.TdsUrl)) item.TdsUrl = catalog.TdsUrl;
|
||||||
|
if (string.IsNullOrWhiteSpace(item.SpecPageUrl)) item.SpecPageUrl = catalog.ProductUrl;
|
||||||
|
|
||||||
|
item.CureTemperatureF ??= catalog.CureTemperatureF;
|
||||||
|
item.CureTimeMinutes ??= catalog.CureTimeMinutes;
|
||||||
|
item.SpecificGravity ??= catalog.SpecificGravity;
|
||||||
|
item.CoverageSqFtPerLb ??= catalog.CoverageSqFtPerLb ?? 30m;
|
||||||
|
item.TransferEfficiency ??= catalog.TransferEfficiency ?? 65m;
|
||||||
|
|
||||||
|
if (!item.RequiresClearCoat && catalog.RequiresClearCoat == true)
|
||||||
|
item.RequiresClearCoat = true;
|
||||||
|
|
||||||
|
if (item.UnitCost <= 0 && catalog.UnitPrice > 0)
|
||||||
|
{
|
||||||
|
item.UnitCost = catalog.UnitPrice;
|
||||||
|
item.LastPurchasePrice = catalog.UnitPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quoting reference price (current catalog list price) — separate from cost basis above.
|
||||||
|
if (catalog.UnitPrice > 0)
|
||||||
|
{
|
||||||
|
item.CatalogReferencePrice = catalog.UnitPrice;
|
||||||
|
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fills blank spec/document fields on a received custom-powder inventory item from the matching
|
||||||
|
/// platform powder catalog row, so the tenant gets a complete record instead of just the color
|
||||||
|
/// code/name carried on the quote. No-op when the powder isn't in the catalog.
|
||||||
|
/// </summary>
|
||||||
|
private async Task EnrichInventoryFromCatalogAsync(
|
||||||
|
InventoryItem item, string? colorCode, string? colorName, string? manufacturer)
|
||||||
|
{
|
||||||
|
var catalog = await FindCatalogByIdentityAsync(colorCode, colorName, manufacturer);
|
||||||
|
if (catalog == null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// First use — lazily fill specific gravity / cure from the TDS before copying onto the item.
|
||||||
|
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||||
|
ApplyCatalogToInventory(item, catalog);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared finalize for a received powder: saves the inventory item, writes the opening Purchase
|
||||||
|
/// transaction, marks the coat received and links it, then links any sibling coats ordering the
|
||||||
|
/// same color. Returns the number of additional coats linked. Used by both the manual modal
|
||||||
|
/// (<see cref="AddCustomPowderToInventory"/>) and the catalog auto-receive
|
||||||
|
/// (<see cref="ReceivePowderFromCatalog"/>).
|
||||||
|
/// </summary>
|
||||||
|
private async Task<int> FinalizeReceivedPowderAsync(
|
||||||
|
JobItemCoat coat, InventoryItem inventoryItem, decimal lbsReceived, int companyId,
|
||||||
|
string? colorCode, string? colorName, int? primaryVendorId, string? jobNumber)
|
||||||
|
{
|
||||||
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
|
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
|
||||||
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
|
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
|
||||||
|
|
||||||
// Opening stock transaction
|
|
||||||
var transaction = new InventoryTransaction
|
var transaction = new InventoryTransaction
|
||||||
{
|
{
|
||||||
CompanyId = companyId,
|
CompanyId = companyId,
|
||||||
InventoryItemId = inventoryItem.Id,
|
InventoryItemId = inventoryItem.Id,
|
||||||
TransactionType = InventoryTransactionType.Purchase,
|
TransactionType = InventoryTransactionType.Purchase,
|
||||||
Quantity = lbsReceived,
|
Quantity = lbsReceived,
|
||||||
UnitCost = unitCost ?? 0,
|
UnitCost = inventoryItem.UnitCost,
|
||||||
TotalCost = lbsReceived * (unitCost ?? 0),
|
TotalCost = lbsReceived * inventoryItem.UnitCost,
|
||||||
TransactionDate = DateTime.UtcNow,
|
TransactionDate = DateTime.UtcNow,
|
||||||
Notes = $"Initial stock — received from powder order for job {jobItem?.Job?.JobNumber}",
|
Notes = $"Initial stock — received from powder order for job {jobNumber}",
|
||||||
BalanceAfter = lbsReceived,
|
BalanceAfter = lbsReceived,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
UpdatedAt = DateTime.UtcNow,
|
UpdatedAt = DateTime.UtcNow,
|
||||||
};
|
};
|
||||||
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
|
||||||
|
|
||||||
// Mark coat as received and link to the new inventory item
|
|
||||||
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||||
coat.PowderReceived = true;
|
coat.PowderReceived = true;
|
||||||
coat.PowderReceivedAt = DateTime.UtcNow;
|
coat.PowderReceivedAt = DateTime.UtcNow;
|
||||||
@@ -793,21 +916,18 @@ public class DashboardController : Controller
|
|||||||
coat.PowderReceivedLbs = lbsReceived;
|
coat.PowderReceivedLbs = lbsReceived;
|
||||||
coat.InventoryItemId = inventoryItem.Id;
|
coat.InventoryItemId = inventoryItem.Id;
|
||||||
|
|
||||||
// Scan for sibling coats with the same custom powder and link them to the new item
|
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coat.Id, companyId);
|
||||||
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
|
|
||||||
|
|
||||||
int linkedCount = 0;
|
var linkedCount = 0;
|
||||||
foreach (var other in candidateCoats)
|
foreach (var other in candidateCoats)
|
||||||
{
|
{
|
||||||
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
|
var colorMatch = !string.IsNullOrWhiteSpace(colorCode)
|
||||||
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
|
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
|
||||||
: !string.IsNullOrWhiteSpace(colorName) &&
|
: !string.IsNullOrWhiteSpace(colorName) &&
|
||||||
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
|
string.Equals(other.ColorName?.Trim(), colorName.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (!colorMatch) continue;
|
if (!colorMatch) continue;
|
||||||
|
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId) continue;
|
||||||
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
other.InventoryItemId = inventoryItem.Id;
|
other.InventoryItemId = inventoryItem.Id;
|
||||||
linkedCount++;
|
linkedCount++;
|
||||||
@@ -818,12 +938,112 @@ public class DashboardController : Controller
|
|||||||
linkedCount, inventoryItem.Id, inventoryItem.SKU);
|
linkedCount, inventoryItem.Id, inventoryItem.SKU);
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
return linkedCount;
|
||||||
|
}
|
||||||
|
|
||||||
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
|
/// <summary>
|
||||||
|
/// Generates a unique powder SKU for a company in the form <c>{CODE}-{YYMM}-{####}</c>, where
|
||||||
|
/// CODE is the (padded) inventory category code. Mirrors the inventory SKU pattern used when
|
||||||
|
/// adding catalog-sourced powders.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<string> GeneratePowderSkuAsync(InventoryCategoryLookup category)
|
||||||
|
{
|
||||||
|
var code = category.CategoryCode.Length >= 4
|
||||||
|
? category.CategoryCode[..4].ToUpperInvariant()
|
||||||
|
: category.CategoryCode.ToUpperInvariant().PadRight(4, 'X');
|
||||||
|
var yearMonth = DateTime.Now.ToString("yyMM");
|
||||||
|
var prefix = $"{code}-{yearMonth}-";
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
return $"{prefix}{(maxSeq + 1):D4}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Receives an ordered custom powder straight into inventory WITHOUT the manual modal when the
|
||||||
|
/// powder is already in the master catalog — the new record is fully populated from the catalog
|
||||||
|
/// (specs, SDS/TDS, image, pricing). Returns <c>needsDetails = true</c> (without saving) when
|
||||||
|
/// the powder isn't in the catalog or no coating category is configured, signaling the caller to
|
||||||
|
/// fall back to the manual entry modal.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> ReceivePowderFromCatalog(int coatId, decimal lbsReceived)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (lbsReceived <= 0)
|
||||||
|
return Json(new { success = false, message = "Quantity received must be greater than zero." });
|
||||||
|
|
||||||
|
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
|
||||||
|
if (coat == null)
|
||||||
|
return Json(new { success = false, message = "Coat record not found." });
|
||||||
|
|
||||||
|
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
|
||||||
|
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
|
||||||
|
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
// Only auto-receive when the powder resolves in the master catalog; otherwise the caller
|
||||||
|
// opens the manual modal.
|
||||||
|
var catalog = await FindCatalogByIdentityAsync(coat.ColorCode, coat.ColorName, null);
|
||||||
|
if (catalog == null)
|
||||||
|
return Json(new { success = false, needsDetails = true });
|
||||||
|
|
||||||
|
// First use — lazily fill specific gravity / cure from the TDS so the new record is complete.
|
||||||
|
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalog);
|
||||||
|
|
||||||
|
// Resolve the company's POWDER (coating) inventory category.
|
||||||
|
var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
|
||||||
|
var coatingCategory = categories.FirstOrDefault(c => c.IsActive && c.IsCoating
|
||||||
|
&& c.CategoryCode.Equals("POWDER", StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? categories.Where(c => c.IsActive && c.IsCoating)
|
||||||
|
.OrderBy(c => c.DisplayOrder).FirstOrDefault();
|
||||||
|
if (coatingCategory == null)
|
||||||
|
return Json(new { success = false, needsDetails = true });
|
||||||
|
|
||||||
|
var sku = await GeneratePowderSkuAsync(coatingCategory);
|
||||||
|
|
||||||
|
var inventoryItem = new InventoryItem
|
||||||
|
{
|
||||||
|
CompanyId = companyId,
|
||||||
|
SKU = sku,
|
||||||
|
Name = catalog.ColorName,
|
||||||
|
ColorName = catalog.ColorName,
|
||||||
|
ColorCode = coat.ColorCode,
|
||||||
|
InventoryCategoryId = coatingCategory.Id,
|
||||||
|
Category = coatingCategory.DisplayName,
|
||||||
|
QuantityOnHand = lbsReceived,
|
||||||
|
UnitOfMeasure = "lbs",
|
||||||
|
UnitCost = catalog.UnitPrice,
|
||||||
|
LastPurchasePrice = catalog.UnitPrice,
|
||||||
|
LastPurchaseDate = DateTime.UtcNow,
|
||||||
|
IsActive = true,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
UpdatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
ApplyCatalogToInventory(inventoryItem, catalog);
|
||||||
|
|
||||||
|
var linkedCount = await FinalizeReceivedPowderAsync(
|
||||||
|
coat, inventoryItem, lbsReceived, companyId, coat.ColorCode, coat.ColorName, null,
|
||||||
|
jobItem?.Job?.JobNumber);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
fromCatalog = true,
|
||||||
|
itemName = inventoryItem.Name,
|
||||||
|
sku = inventoryItem.SKU,
|
||||||
|
linkedCount
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Error adding custom powder to inventory for coat {CoatId}", coatId);
|
_logger.LogError(ex, "Error auto-receiving powder from catalog for coat {CoatId}", coatId);
|
||||||
return Json(new { success = false, message = "An error occurred while saving." });
|
return Json(new { success = false, message = "An error occurred while saving." });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ using PowderCoating.Core.Entities;
|
|||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
using PowderCoating.Shared.Constants;
|
using PowderCoating.Shared.Constants;
|
||||||
|
using PowderCoating.Web.Helpers;
|
||||||
using QuestPDF.Fluent;
|
using QuestPDF.Fluent;
|
||||||
using QuestPDF.Helpers;
|
using QuestPDF.Helpers;
|
||||||
using QuestPDF.Infrastructure;
|
using QuestPDF.Infrastructure;
|
||||||
@@ -63,7 +64,8 @@ public class DepositsController : Controller
|
|||||||
string paymentMethod,
|
string paymentMethod,
|
||||||
DateTime receivedDate,
|
DateTime receivedDate,
|
||||||
string? reference,
|
string? reference,
|
||||||
string? notes)
|
string? notes,
|
||||||
|
int? depositAccountId = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -80,7 +82,32 @@ public class DepositsController : Controller
|
|||||||
if (currentUser == null) return Unauthorized();
|
if (currentUser == null) return Unauthorized();
|
||||||
|
|
||||||
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
var receiptNumber = await GenerateReceiptNumberAsync(currentUser.CompanyId);
|
||||||
var checkingAcctId = await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
|
||||||
|
// Resolve the bank/asset account the deposit lands in. The user now picks this on
|
||||||
|
// the form; if they didn't (or the value is stale), fall back to the legacy
|
||||||
|
// auto-pick of the first Checking/Cash account. Validate any user-supplied id
|
||||||
|
// belongs to this company (defense in depth — the global filter alone isn't enough).
|
||||||
|
int? depositAcctId = null;
|
||||||
|
if (depositAccountId.HasValue &&
|
||||||
|
await AccountGuard.IsValidMoneyAccountAsync(_unitOfWork, depositAccountId, currentUser.CompanyId))
|
||||||
|
{
|
||||||
|
depositAcctId = depositAccountId;
|
||||||
|
}
|
||||||
|
depositAcctId ??= await GetCheckingAccountIdAsync(currentUser.CompanyId);
|
||||||
|
|
||||||
|
// Guard against an unbalanced GL posting: this deposit credits the Customer Deposits
|
||||||
|
// liability (2300). If that account exists but we have no bank/asset account to debit,
|
||||||
|
// the entry would be one-sided. Block it so the user picks a deposit account first.
|
||||||
|
// (When 2300 doesn't exist — e.g. a company not using accounting — no GL posts at all,
|
||||||
|
// so a missing bank account is harmless and the deposit is allowed through.)
|
||||||
|
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
||||||
|
if (custDepositsAcctId != null && depositAcctId == null)
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = false,
|
||||||
|
message = "Select a deposit account (the bank/asset account this payment lands in) " +
|
||||||
|
"before recording. None is configured for your company yet."
|
||||||
|
});
|
||||||
|
|
||||||
var deposit = new Deposit
|
var deposit = new Deposit
|
||||||
{
|
{
|
||||||
@@ -93,7 +120,7 @@ public class DepositsController : Controller
|
|||||||
ReceivedDate = receivedDate,
|
ReceivedDate = receivedDate,
|
||||||
Reference = reference,
|
Reference = reference,
|
||||||
Notes = notes,
|
Notes = notes,
|
||||||
DepositAccountId = checkingAcctId,
|
DepositAccountId = depositAcctId,
|
||||||
RecordedById = currentUser.Id,
|
RecordedById = currentUser.Id,
|
||||||
CompanyId = currentUser.CompanyId,
|
CompanyId = currentUser.CompanyId,
|
||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
@@ -104,8 +131,7 @@ public class DepositsController : Controller
|
|||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
|
// GL: DR Checking (cash received) / CR Customer Deposits 2300 (liability until applied to invoice).
|
||||||
var custDepositsAcctId = await GetCustomerDepositsAccountIdAsync(currentUser.CompanyId);
|
await _accountBalanceService.DebitAsync(depositAcctId, deposit.Amount);
|
||||||
await _accountBalanceService.DebitAsync(checkingAcctId, deposit.Amount);
|
|
||||||
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
|
await _accountBalanceService.CreditAsync(custDepositsAcctId, deposit.Amount);
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ public class ExpensesController : Controller
|
|||||||
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
||||||
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
|
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
|
||||||
|
|
||||||
|
var legacyUser = await _userManager.GetUserAsync(User);
|
||||||
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
|
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive &&
|
a => a.CompanyId == legacyUser!.CompanyId && a.IsActive &&
|
||||||
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
|
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -479,7 +480,8 @@ public class ExpensesController : Controller
|
|||||||
|
|
||||||
if (!request.AvailableAccounts.Any())
|
if (!request.AvailableAccounts.Any())
|
||||||
{
|
{
|
||||||
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == currentUser!.CompanyId && a.IsActive);
|
||||||
request.AvailableAccounts = allAccounts
|
request.AvailableAccounts = allAccounts
|
||||||
.Where(a => a.AccountType == AccountType.Expense ||
|
.Where(a => a.AccountType == AccountType.Expense ||
|
||||||
a.AccountType == AccountType.CostOfGoods)
|
a.AccountType == AccountType.CostOfGoods)
|
||||||
|
|||||||
@@ -44,8 +44,9 @@ public class FixedAssetsController : Controller
|
|||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> Index()
|
public async Task<IActionResult> Index()
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||||
fa => true, false,
|
fa => fa.CompanyId == companyId, false,
|
||||||
fa => fa.AssetAccount,
|
fa => fa.AssetAccount,
|
||||||
fa => fa.DepreciationExpenseAccount,
|
fa => fa.DepreciationExpenseAccount,
|
||||||
fa => fa.AccumDepreciationAccount);
|
fa => fa.AccumDepreciationAccount);
|
||||||
@@ -192,7 +193,7 @@ public class FixedAssetsController : Controller
|
|||||||
var currentUser = await _userManager.GetUserAsync(User);
|
var currentUser = await _userManager.GetUserAsync(User);
|
||||||
|
|
||||||
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
var assets = await _unitOfWork.FixedAssets.FindAsync(
|
||||||
fa => !fa.IsDisposed, false,
|
fa => fa.CompanyId == companyId && !fa.IsDisposed, false,
|
||||||
fa => fa.DepreciationEntries);
|
fa => fa.DepreciationEntries);
|
||||||
|
|
||||||
int posted = 0, skipped = 0;
|
int posted = 0, skipped = 0;
|
||||||
@@ -313,7 +314,8 @@ public class FixedAssetsController : Controller
|
|||||||
|
|
||||||
private async Task PopulateAccountsAsync()
|
private async Task PopulateAccountsAsync()
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||||
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
|
var list = accounts.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name).ToList();
|
||||||
|
|
||||||
ViewBag.AssetAccounts = list
|
ViewBag.AssetAccounts = list
|
||||||
|
|||||||
@@ -62,8 +62,9 @@ public class GiftCertificatesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<IActionResult> Index(string? searchTerm, string? statusFilter)
|
public async Task<IActionResult> Index(string? searchTerm, string? statusFilter)
|
||||||
{
|
{
|
||||||
|
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||||
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
var certs = await _unitOfWork.GiftCertificates.FindAsync(
|
||||||
gc => true, false,
|
gc => gc.CompanyId == companyId, false,
|
||||||
gc => gc.RecipientCustomer,
|
gc => gc.RecipientCustomer,
|
||||||
gc => gc.PurchasingCustomer);
|
gc => gc.PurchasingCustomer);
|
||||||
|
|
||||||
@@ -254,14 +255,14 @@ public class GiftCertificatesController : Controller
|
|||||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||||
{
|
{
|
||||||
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var checkingAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||||
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
|
await _accountBalanceService.DebitAsync(checkingAcctId?.Id, cert.OriginalAmount);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var discountAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && a.AccountNumber == "4950");
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
|
||||||
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
|
await _accountBalanceService.DebitAsync(discountAcctId?.Id, cert.OriginalAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +311,7 @@ public class GiftCertificatesController : Controller
|
|||||||
var companyId = currentUser?.CompanyId ?? 0;
|
var companyId = currentUser?.CompanyId ?? 0;
|
||||||
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
var gcLiabilityAcctId = await GetGcLiabilityAccountIdAsync(companyId);
|
||||||
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var otherIncomeAcctId = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
|
a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubTypeEnum.OtherIncome);
|
||||||
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
|
await _accountBalanceService.DebitAsync(gcLiabilityAcctId, remaining);
|
||||||
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
|
await _accountBalanceService.CreditAsync(otherIncomeAcctId?.Id, remaining);
|
||||||
}
|
}
|
||||||
@@ -420,7 +421,8 @@ public class GiftCertificatesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateCustomersAsync()
|
private async Task PopulateCustomersAsync()
|
||||||
{
|
{
|
||||||
var customers = await _unitOfWork.Customers.FindAsync(c => c.IsActive);
|
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||||
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.IsActive);
|
||||||
var list = customers
|
var list = customers
|
||||||
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
|
||||||
.Select(c => new SelectListItem
|
.Select(c => new SelectListItem
|
||||||
@@ -437,7 +439,7 @@ public class GiftCertificatesController : Controller
|
|||||||
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && a.AccountNumber == "2500");
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
|
||||||
return acct?.Id;
|
return acct?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,14 +479,14 @@ public class GiftCertificatesController : Controller
|
|||||||
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
if (dto.IssuedReason == GiftCertificateIssuedReason.Sold)
|
||||||
{
|
{
|
||||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubTypeEnum.Checking
|
||||||
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
|| a.AccountSubType == AccountSubTypeEnum.Cash));
|
||||||
checkingAcctId = acct?.Id;
|
checkingAcctId = acct?.Id;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && a.AccountNumber == "4950");
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4950");
|
||||||
discountAcctId = acct?.Id;
|
discountAcctId = acct?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,11 +126,12 @@ public class InAppNotificationsController : Controller
|
|||||||
public async Task<IActionResult> MarkAllRead()
|
public async Task<IActionResult> MarkAllRead()
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var companyId = _tenant.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
var unread = _tenant.IsPlatformAdmin()
|
var unread = _tenant.IsPlatformAdmin()
|
||||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
|
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
|
||||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList();
|
: (await _unitOfWork.InAppNotifications.FindAsync(n => n.CompanyId == companyId && !n.IsRead)).ToList();
|
||||||
|
|
||||||
foreach (var n in unread)
|
foreach (var n in unread)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -193,8 +193,9 @@ public class InventoryController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
var loc = location.Trim();
|
var loc = location.Trim();
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var items = await _unitOfWork.InventoryItems.FindAsync(
|
var items = await _unitOfWork.InventoryItems.FindAsync(
|
||||||
i => i.Location != null && i.Location.ToLower() == loc.ToLower());
|
i => i.CompanyId == companyId && i.Location != null && i.Location.ToLower() == loc.ToLower());
|
||||||
|
|
||||||
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
|
var dtos = _mapper.Map<List<InventoryListDto>>(items.OrderBy(i => i.Name).ToList());
|
||||||
ViewBag.Location = loc;
|
ViewBag.Location = loc;
|
||||||
@@ -240,6 +241,17 @@ public class InventoryController : Controller
|
|||||||
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
var useMetric = await _tenantContext.UseMetricSystemAsync();
|
||||||
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
||||||
|
|
||||||
|
// Manufacturer-level catalog status: prefer the linked catalog row, fall back to an
|
||||||
|
// identity match for items added before they were linked. Drives the "discontinued by
|
||||||
|
// manufacturer — cannot reorder" warning. This is distinct from the shop's own
|
||||||
|
// IsActive/DiscontinuedDate (whether the shop still stocks it).
|
||||||
|
var catalogItem = item.PowderCatalogItemId.HasValue
|
||||||
|
? await _unitOfWork.PowderCatalog.GetByIdAsync(item.PowderCatalogItemId.Value)
|
||||||
|
: await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
|
||||||
|
ViewBag.CatalogDiscontinued = catalogItem?.IsDiscontinued ?? false;
|
||||||
|
ViewBag.CatalogVendorName = catalogItem?.VendorName;
|
||||||
|
ViewBag.CatalogProductUrl = catalogItem?.ProductUrl;
|
||||||
|
|
||||||
return View(itemDto);
|
return View(itemDto);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -264,10 +276,18 @@ public class InventoryController : Controller
|
|||||||
ViewBag.UseMetric = useMetric;
|
ViewBag.UseMetric = useMetric;
|
||||||
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
ViewBag.CoverageUnit = _measurementService.GetCoverageUnitLabel(useMetric);
|
||||||
|
|
||||||
|
// Pre-fill the GL account dropdowns from the company's configured defaults so new items
|
||||||
|
// inherit them (the user can still change or clear them on the form).
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
|
||||||
return View(new CreateInventoryItemDto
|
return View(new CreateInventoryItemDto
|
||||||
{
|
{
|
||||||
CoverageSqFtPerLb = 30,
|
CoverageSqFtPerLb = 30,
|
||||||
TransferEfficiency = 65
|
TransferEfficiency = 65,
|
||||||
|
InventoryAccountId = prefs?.DefaultInventoryAccountId,
|
||||||
|
CogsAccountId = prefs?.DefaultCogsAccountId
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +307,27 @@ public class InventoryController : Controller
|
|||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var category = dto.InventoryCategoryId.HasValue
|
||||||
|
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(dto.InventoryCategoryId.Value)
|
||||||
|
: null;
|
||||||
|
var duplicate = await FindInventoryDuplicateAsync(
|
||||||
|
dto.SKU,
|
||||||
|
dto.Manufacturer,
|
||||||
|
dto.ManufacturerPartNumber,
|
||||||
|
dto.ColorName,
|
||||||
|
category?.IsCoating == true);
|
||||||
|
|
||||||
|
if (duplicate != null &&
|
||||||
|
(duplicate.MatchType == InventoryDuplicateMatchType.Sku ||
|
||||||
|
dto.DuplicateOverrideInventoryItemId != duplicate.Item.Id))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(
|
||||||
|
duplicate.MatchType == InventoryDuplicateMatchType.Sku ? nameof(dto.SKU) : string.Empty,
|
||||||
|
BuildDuplicateMessage(duplicate));
|
||||||
|
await PopulateDropdowns();
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var item = _mapper.Map<InventoryItem>(dto);
|
var item = _mapper.Map<InventoryItem>(dto);
|
||||||
@@ -295,11 +336,21 @@ public class InventoryController : Controller
|
|||||||
item.Name = ToTitleCase(item.Name);
|
item.Name = ToTitleCase(item.Name);
|
||||||
|
|
||||||
// Populate legacy Category field from lookup table
|
// Populate legacy Category field from lookup table
|
||||||
if (item.InventoryCategoryId.HasValue)
|
|
||||||
{
|
|
||||||
var category = await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(item.InventoryCategoryId.Value);
|
|
||||||
if (category != null)
|
if (category != null)
|
||||||
item.Category = category.DisplayName;
|
item.Category = category.DisplayName;
|
||||||
|
|
||||||
|
// Link to the platform catalog row when this item's identity matches one, so the detail
|
||||||
|
// screen can show manufacturer-level status (discontinued / cannot reorder) and quotes
|
||||||
|
// can use the current catalog price.
|
||||||
|
var catalogMatch = await FindCatalogMatchAsync(item.Manufacturer, item.ManufacturerPartNumber);
|
||||||
|
if (catalogMatch != null)
|
||||||
|
{
|
||||||
|
item.PowderCatalogItemId = catalogMatch.Id;
|
||||||
|
if (catalogMatch.UnitPrice > 0)
|
||||||
|
{
|
||||||
|
item.CatalogReferencePrice = catalogMatch.UnitPrice;
|
||||||
|
item.CatalogPriceUpdatedAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.InventoryItems.AddAsync(item);
|
await _unitOfWork.InventoryItems.AddAsync(item);
|
||||||
@@ -763,6 +814,24 @@ public class InventoryController : Controller
|
|||||||
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
|
/// Returns (wasInCatalog, addedToCatalog) so callers can surface UI badges.
|
||||||
/// Mutates <paramref name="result"/> in place.
|
/// Mutates <paramref name="result"/> in place.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Finds the platform powder catalog row matching an inventory item's identity
|
||||||
|
/// (Manufacturer + ManufacturerPartNumber), or null. Used to set
|
||||||
|
/// <see cref="InventoryItem.PowderCatalogItemId"/> and to surface manufacturer-level status
|
||||||
|
/// (e.g. discontinued / cannot reorder) on the detail screen.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<PowderCatalogItem?> FindCatalogMatchAsync(string? manufacturer, string? sku)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(manufacturer) || string.IsNullOrWhiteSpace(sku))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var skuLower = sku.Trim().ToLower();
|
||||||
|
var mfrLower = manufacturer.Trim().ToLower();
|
||||||
|
var hits = await _unitOfWork.PowderCatalog.FindAsync(p =>
|
||||||
|
p.Sku.ToLower() == skuLower && p.VendorName.ToLower().Contains(mfrLower));
|
||||||
|
return hits.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
|
private async Task<(bool wasInCatalog, bool addedToCatalog)> EnrichFromCatalogAsync(
|
||||||
InventoryAiLookupResult result, bool autoContribute)
|
InventoryAiLookupResult result, bool autoContribute)
|
||||||
{
|
{
|
||||||
@@ -999,45 +1068,12 @@ public class InventoryController : Controller
|
|||||||
// TDS cure fallback — same logic as AiLookup button
|
// TDS cure fallback — same logic as AiLookup button
|
||||||
await ApplyTdsCureFallbackAsync(aiResult, colorName);
|
await ApplyTdsCureFallbackAsync(aiResult, colorName);
|
||||||
|
|
||||||
// Check if this product already exists in the tenant's inventory.
|
var duplicate = await FindInventoryDuplicateAsync(
|
||||||
// Match by ManufacturerPartNumber first (most precise); fall back to color name + manufacturer.
|
null,
|
||||||
// Returns the first active match so the UI can prompt to add stock inline.
|
manufacturer,
|
||||||
int? existingInventoryId = null;
|
sku,
|
||||||
string? existingInventoryName = null;
|
colorName,
|
||||||
decimal? existingQuantityOnHand = null;
|
isCoating: true);
|
||||||
string? existingUnitOfMeasure = null;
|
|
||||||
|
|
||||||
InventoryItem? existingHit = null;
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(sku))
|
|
||||||
{
|
|
||||||
var skuLower = sku.ToLower();
|
|
||||||
var byPart = await _unitOfWork.InventoryItems.FindAsync(i =>
|
|
||||||
i.ManufacturerPartNumber != null &&
|
|
||||||
i.ManufacturerPartNumber.ToLower() == skuLower);
|
|
||||||
existingHit = byPart.FirstOrDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingHit == null && !string.IsNullOrEmpty(colorName))
|
|
||||||
{
|
|
||||||
var nameLower = colorName.ToLower();
|
|
||||||
var mfrLower = manufacturer?.ToLower() ?? "";
|
|
||||||
var byName = await _unitOfWork.InventoryItems.FindAsync(i =>
|
|
||||||
(i.ColorName != null && i.ColorName.ToLower() == nameLower) ||
|
|
||||||
i.Name.ToLower() == nameLower);
|
|
||||||
existingHit = byName.FirstOrDefault(i =>
|
|
||||||
string.IsNullOrEmpty(mfrLower) ||
|
|
||||||
(i.Manufacturer ?? "").ToLower().Contains(mfrLower) ||
|
|
||||||
mfrLower.Contains((i.Manufacturer ?? "").ToLower().Trim()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingHit != null)
|
|
||||||
{
|
|
||||||
existingInventoryId = existingHit.Id;
|
|
||||||
existingInventoryName = existingHit.Name;
|
|
||||||
existingQuantityOnHand = existingHit.QuantityOnHand;
|
|
||||||
existingUnitOfMeasure = existingHit.UnitOfMeasure;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
@@ -1062,16 +1098,61 @@ public class InventoryController : Controller
|
|||||||
vendorName = manufacturer,
|
vendorName = manufacturer,
|
||||||
wasInCatalog = wasInCatalog,
|
wasInCatalog = wasInCatalog,
|
||||||
addedToCatalog = addedToCatalog,
|
addedToCatalog = addedToCatalog,
|
||||||
existingInventoryId = existingInventoryId,
|
existingInventoryId = duplicate?.Item.Id,
|
||||||
existingInventoryName = existingInventoryName,
|
existingInventoryName = duplicate?.Item.Name,
|
||||||
existingQuantityOnHand = existingQuantityOnHand,
|
existingQuantityOnHand = duplicate?.Item.QuantityOnHand,
|
||||||
existingUnitOfMeasure = existingUnitOfMeasure,
|
existingUnitOfMeasure = duplicate?.Item.UnitOfMeasure,
|
||||||
|
duplicateMatchType = duplicate?.MatchType.ToString(),
|
||||||
reasoning = aiResult.Reasoning,
|
reasoning = aiResult.Reasoning,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds stock to an existing inventory item from the label scanner inline prompt.
|
/// Checks the current tenant's active inventory for an existing SKU or powder identity.
|
||||||
|
/// Uses the same matcher as label scanning and repeats the tenant boundary explicitly.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> CheckDuplicate(
|
||||||
|
string? sku,
|
||||||
|
int? categoryId,
|
||||||
|
string? manufacturer,
|
||||||
|
string? manufacturerPartNumber,
|
||||||
|
string? colorName,
|
||||||
|
int? currentId = null)
|
||||||
|
{
|
||||||
|
var category = categoryId.HasValue
|
||||||
|
? await _unitOfWork.InventoryCategoryLookups.GetByIdAsync(categoryId.Value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var duplicate = await FindInventoryDuplicateAsync(
|
||||||
|
sku,
|
||||||
|
manufacturer,
|
||||||
|
manufacturerPartNumber,
|
||||||
|
colorName,
|
||||||
|
category?.IsCoating == true,
|
||||||
|
currentId);
|
||||||
|
|
||||||
|
if (duplicate == null)
|
||||||
|
return Json(new { hasDuplicate = false });
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
hasDuplicate = true,
|
||||||
|
isBlocking = duplicate.MatchType == InventoryDuplicateMatchType.Sku,
|
||||||
|
matchType = duplicate.MatchType.ToString(),
|
||||||
|
message = BuildDuplicateMessage(duplicate),
|
||||||
|
existingInventoryId = duplicate.Item.Id,
|
||||||
|
existingInventoryName = duplicate.Item.Name,
|
||||||
|
existingSku = duplicate.Item.SKU,
|
||||||
|
existingManufacturer = duplicate.Item.Manufacturer,
|
||||||
|
existingColorName = duplicate.Item.ColorName,
|
||||||
|
existingQuantityOnHand = duplicate.Item.QuantityOnHand,
|
||||||
|
existingUnitOfMeasure = duplicate.Item.UnitOfMeasure,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds stock to an existing inventory item from the shared duplicate prompt.
|
||||||
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
|
/// Creates a Purchase transaction and updates QuantityOnHand without navigating away.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -1220,6 +1301,10 @@ public class InventoryController : Controller
|
|||||||
if (catalogItem == null)
|
if (catalogItem == null)
|
||||||
return Json(new { success = false, error = "Catalog item not found." });
|
return Json(new { success = false, error = "Catalog item not found." });
|
||||||
|
|
||||||
|
// First use of this powder — lazily fill specific gravity / cure from its TDS so the new
|
||||||
|
// inventory record (and the catalog) carry complete specs. No-op once already enriched.
|
||||||
|
await _aiLookupService.EnsureCatalogTdsSpecsAsync(catalogItem);
|
||||||
|
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Find the default coating category to assign.
|
// Find the default coating category to assign.
|
||||||
@@ -1257,6 +1342,7 @@ public class InventoryController : Controller
|
|||||||
ColorName = catalogItem.ColorName,
|
ColorName = catalogItem.ColorName,
|
||||||
Manufacturer = catalogItem.VendorName,
|
Manufacturer = catalogItem.VendorName,
|
||||||
ManufacturerPartNumber= catalogItem.Sku,
|
ManufacturerPartNumber= catalogItem.Sku,
|
||||||
|
PowderCatalogItemId = catalogItem.Id,
|
||||||
Finish = catalogItem.Finish,
|
Finish = catalogItem.Finish,
|
||||||
ColorFamilies = catalogItem.ColorFamilies,
|
ColorFamilies = catalogItem.ColorFamilies,
|
||||||
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
RequiresClearCoat = catalogItem.RequiresClearCoat ?? false,
|
||||||
@@ -1272,6 +1358,8 @@ public class InventoryController : Controller
|
|||||||
UnitCost = catalogItem.UnitPrice,
|
UnitCost = catalogItem.UnitPrice,
|
||||||
AverageCost = catalogItem.UnitPrice,
|
AverageCost = catalogItem.UnitPrice,
|
||||||
LastPurchasePrice = catalogItem.UnitPrice,
|
LastPurchasePrice = catalogItem.UnitPrice,
|
||||||
|
CatalogReferencePrice = catalogItem.UnitPrice > 0 ? catalogItem.UnitPrice : (decimal?)null,
|
||||||
|
CatalogPriceUpdatedAt = catalogItem.UnitPrice > 0 ? DateTime.UtcNow : (DateTime?)null,
|
||||||
QuantityOnHand = 0,
|
QuantityOnHand = 0,
|
||||||
UnitOfMeasure = "lbs",
|
UnitOfMeasure = "lbs",
|
||||||
InventoryCategoryId = coatingCategory.Id,
|
InventoryCategoryId = coatingCategory.Id,
|
||||||
@@ -1297,7 +1385,7 @@ public class InventoryController : Controller
|
|||||||
efficiency = item.TransferEfficiency ?? 65m,
|
efficiency = item.TransferEfficiency ?? 65m,
|
||||||
unitOfMeasure= item.UnitOfMeasure,
|
unitOfMeasure= item.UnitOfMeasure,
|
||||||
categoryName = coatingCategory.DisplayName,
|
categoryName = coatingCategory.DisplayName,
|
||||||
costPerLb = item.UnitCost,
|
costPerLb = item.CatalogReferencePrice ?? item.UnitCost,
|
||||||
colorName = item.ColorName ?? item.Name,
|
colorName = item.ColorName ?? item.Name,
|
||||||
colorCode = "",
|
colorCode = "",
|
||||||
isIncoming = true
|
isIncoming = true
|
||||||
@@ -1310,6 +1398,48 @@ public class InventoryController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<InventoryDuplicateMatch?> FindInventoryDuplicateAsync(
|
||||||
|
string? sku,
|
||||||
|
string? manufacturer,
|
||||||
|
string? manufacturerPartNumber,
|
||||||
|
string? colorName,
|
||||||
|
bool isCoating,
|
||||||
|
int? excludeId = null)
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (!companyId.HasValue || companyId.Value <= 0)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Explicit CompanyId predicate is intentional defense-in-depth on top of the global filter.
|
||||||
|
var tenantInventory = await _unitOfWork.InventoryItems.FindAsync(
|
||||||
|
i => i.CompanyId == companyId.Value,
|
||||||
|
false,
|
||||||
|
i => i.InventoryCategory!);
|
||||||
|
|
||||||
|
return InventoryDuplicateMatcher.Find(
|
||||||
|
tenantInventory,
|
||||||
|
companyId.Value,
|
||||||
|
sku,
|
||||||
|
manufacturer,
|
||||||
|
manufacturerPartNumber,
|
||||||
|
colorName,
|
||||||
|
isCoating,
|
||||||
|
excludeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildDuplicateMessage(InventoryDuplicateMatch duplicate)
|
||||||
|
{
|
||||||
|
return duplicate.MatchType switch
|
||||||
|
{
|
||||||
|
InventoryDuplicateMatchType.Sku =>
|
||||||
|
$"SKU '{duplicate.Item.SKU}' is already used by '{duplicate.Item.Name}'.",
|
||||||
|
InventoryDuplicateMatchType.ManufacturerPartNumber =>
|
||||||
|
$"This manufacturer's part number is already recorded as '{duplicate.Item.Name}' ({duplicate.Item.SKU}).",
|
||||||
|
_ =>
|
||||||
|
$"{duplicate.Item.Manufacturer} {duplicate.Item.ColorName ?? duplicate.Item.Name} is already in inventory as '{duplicate.Item.Name}' ({duplicate.Item.SKU})."
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
private static decimal GetEffectiveTransferEfficiency(decimal? transferEfficiency)
|
||||||
{
|
{
|
||||||
return transferEfficiency ?? DefaultTransferEfficiency;
|
return transferEfficiency ?? DefaultTransferEfficiency;
|
||||||
@@ -1402,8 +1532,9 @@ public class InventoryController : Controller
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
|
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
|
||||||
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
|
i => i.CompanyId == companyId && i.InventoryCategory != null && i.InventoryCategory.IsCoating,
|
||||||
false,
|
false,
|
||||||
i => i.InventoryCategory))
|
i => i.InventoryCategory))
|
||||||
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
|
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
|
||||||
@@ -1480,7 +1611,7 @@ public class InventoryController : Controller
|
|||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId);
|
||||||
|
|
||||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive, false, v => v.Categories))
|
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive, false, v => v.Categories))
|
||||||
.OrderBy(v => v.CompanyName).ToList();
|
.OrderBy(v => v.CompanyName).ToList();
|
||||||
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
|
ViewBag.Vendors = new SelectList(vendors, "Id", "CompanyName");
|
||||||
|
|
||||||
@@ -1519,12 +1650,17 @@ public class InventoryController : Controller
|
|||||||
new SelectListItem { Value = "rolls", Text = "Rolls" }
|
new SelectListItem { Value = "rolls", Text = "Rolls" }
|
||||||
};
|
};
|
||||||
|
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||||
|
|
||||||
|
// Show ALL asset accounts, not just the Inventory sub-type. Companies that created
|
||||||
|
// their inventory account manually often land on a different asset sub-type (e.g.
|
||||||
|
// Other Current Asset), which previously left this dropdown empty. Listing every
|
||||||
|
// asset account lets them pick whatever they actually use; Inventory sub-type
|
||||||
|
// accounts are surfaced first as the recommended choice.
|
||||||
ViewBag.InventoryAccounts = accounts
|
ViewBag.InventoryAccounts = accounts
|
||||||
.Where(a => a.AccountType == AccountType.Asset
|
.Where(a => a.AccountType == AccountType.Asset)
|
||||||
&& a.AccountSubType == AccountSubType.Inventory)
|
.OrderByDescending(a => a.AccountSubType == AccountSubType.Inventory)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.ThenBy(a => a.AccountNumber)
|
||||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -1533,6 +1669,13 @@ public class InventoryController : Controller
|
|||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// Whether the company has configured default accounts — the views use this to label the
|
||||||
|
// blank dropdown option "(Default …)" vs "(None)".
|
||||||
|
var prefs = await _unitOfWork.CompanyPreferences
|
||||||
|
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
|
||||||
|
ViewBag.HasDefaultInventoryAccount = prefs?.DefaultInventoryAccountId != null;
|
||||||
|
ViewBag.HasDefaultCogsAccount = prefs?.DefaultCogsAccountId != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1710,13 +1853,16 @@ public class InventoryController : Controller
|
|||||||
item.UpdatedAt = DateTime.UtcNow;
|
item.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
await _unitOfWork.InventoryItems.UpdateAsync(item);
|
||||||
|
|
||||||
|
// Record at the effective (weighted-average) unit cost so TotalCost equals the COGS actually
|
||||||
|
// posted — the GL recompute reads TotalCost to reproduce the DR COGS / CR Inventory entry.
|
||||||
|
var effectiveUnitCost = item.AverageCost > 0 ? item.AverageCost : item.UnitCost;
|
||||||
var txn = new InventoryTransaction
|
var txn = new InventoryTransaction
|
||||||
{
|
{
|
||||||
InventoryItemId = item.Id,
|
InventoryItemId = item.Id,
|
||||||
TransactionType = transactionType,
|
TransactionType = transactionType,
|
||||||
Quantity = -quantityUsed,
|
Quantity = -quantityUsed,
|
||||||
UnitCost = item.UnitCost,
|
UnitCost = effectiveUnitCost,
|
||||||
TotalCost = quantityUsed * item.UnitCost,
|
TotalCost = quantityUsed * effectiveUnitCost,
|
||||||
TransactionDate = DateTime.UtcNow,
|
TransactionDate = DateTime.UtcNow,
|
||||||
BalanceAfter = item.QuantityOnHand,
|
BalanceAfter = item.QuantityOnHand,
|
||||||
JobId = jobId,
|
JobId = jobId,
|
||||||
@@ -1730,7 +1876,7 @@ public class InventoryController : Controller
|
|||||||
|
|
||||||
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
if (item.CogsAccountId.HasValue && item.InventoryAccountId.HasValue)
|
||||||
{
|
{
|
||||||
var cost = quantityUsed * (item.AverageCost > 0 ? item.AverageCost : item.UnitCost);
|
var cost = txn.TotalCost;
|
||||||
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
await _accountBalanceService.DebitAsync(item.CogsAccountId, cost);
|
||||||
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
await _accountBalanceService.CreditAsync(item.InventoryAccountId, cost);
|
||||||
}
|
}
|
||||||
@@ -2081,7 +2227,7 @@ public class InventoryController : Controller
|
|||||||
return BadRequest("Only usage transactions can be edited here.");
|
return BadRequest("Only usage transactions can be edited here.");
|
||||||
|
|
||||||
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
var allJobs = await _unitOfWork.Jobs.FindAsync(
|
||||||
j => !j.JobStatus.IsTerminalStatus,
|
j => j.CompanyId == txn.CompanyId && !j.JobStatus.IsTerminalStatus,
|
||||||
false,
|
false,
|
||||||
j => j.Customer,
|
j => j.Customer,
|
||||||
j => j.JobStatus);
|
j => j.JobStatus);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using PowderCoating.Application.DTOs.Common;
|
|||||||
using PowderCoating.Application.DTOs.Invoice;
|
using PowderCoating.Application.DTOs.Invoice;
|
||||||
using PowderCoating.Application.DTOs.Quote;
|
using PowderCoating.Application.DTOs.Quote;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
|
using PowderCoating.Core.Accounting;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
@@ -240,11 +241,12 @@ public class InvoicesController : Controller
|
|||||||
ViewBag.SortDirection = gridRequest.SortDirection;
|
ViewBag.SortDirection = gridRequest.SortDirection;
|
||||||
|
|
||||||
// Pill badge counts — always global (not scoped to current filter/page)
|
// Pill badge counts — always global (not scoped to current filter/page)
|
||||||
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i =>
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue);
|
ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId &&
|
||||||
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid);
|
(i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue));
|
||||||
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid);
|
ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.PartiallyPaid);
|
||||||
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync();
|
ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.Paid);
|
||||||
|
ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId);
|
||||||
|
|
||||||
return View(pagedResult);
|
return View(pagedResult);
|
||||||
}
|
}
|
||||||
@@ -304,8 +306,9 @@ public class InvoicesController : Controller
|
|||||||
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
&& company?.StripeConnectStatus == StripeConnectStatus.Active;
|
||||||
|
|
||||||
// Expense accounts for the write-off bad-debt modal
|
// Expense accounts for the write-off bad-debt modal
|
||||||
|
var expenseCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
var expenseAccounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
a => a.CompanyId == expenseCompanyId && a.IsActive && a.AccountType == AccountType.Expense);
|
||||||
ViewBag.ExpenseAccounts = expenseAccounts
|
ViewBag.ExpenseAccounts = expenseAccounts
|
||||||
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
|
.OrderBy(a => a.AccountNumber).ThenBy(a => a.Name)
|
||||||
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||||
@@ -410,8 +413,15 @@ public class InvoicesController : Controller
|
|||||||
.ToDictionary(ci => ci.Id)
|
.ToDictionary(ci => ci.Id)
|
||||||
: new Dictionary<int, CatalogItem>();
|
: new Dictionary<int, CatalogItem>();
|
||||||
|
|
||||||
// Fall back to the default revenue account (4000) if a catalog item has no specific account
|
// Fall back to the company's configured default revenue account when a catalog item
|
||||||
var defaultRevenueAccount = await _unitOfWork.Accounts
|
// has no specific account; if none is configured (or it has since been deactivated),
|
||||||
|
// fall back to the seeded 4000 account. The IsActive check mirrors the 4000 lookup so a
|
||||||
|
// deactivated default doesn't keep being posted to.
|
||||||
|
Account? defaultRevenueAccount = null;
|
||||||
|
if (prefs?.DefaultRevenueAccountId != null)
|
||||||
|
defaultRevenueAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == prefs.DefaultRevenueAccountId.Value && a.IsActive);
|
||||||
|
defaultRevenueAccount ??= await _unitOfWork.Accounts
|
||||||
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
.FirstOrDefaultAsync(a => a.AccountNumber == "4000" && a.IsActive);
|
||||||
|
|
||||||
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
|
// Deserialize the job's pricing snapshot up front — it is authoritative for discount,
|
||||||
@@ -2404,7 +2414,7 @@ public class InvoicesController : Controller
|
|||||||
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
|
return Json(new { taxPercent = 0m, taxRateName = (string?)null });
|
||||||
|
|
||||||
var defaultRate = await _unitOfWork.TaxRates
|
var defaultRate = await _unitOfWork.TaxRates
|
||||||
.FirstOrDefaultAsync(r => r.IsDefault && r.IsActive && !r.IsDeleted);
|
.FirstOrDefaultAsync(r => r.CompanyId == customer.CompanyId && r.IsDefault && r.IsActive && !r.IsDeleted);
|
||||||
|
|
||||||
return Json(new
|
return Json(new
|
||||||
{
|
{
|
||||||
@@ -2441,7 +2451,7 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
|
// Merchandise items for the invoice merch picker (all active IsMerchandise items)
|
||||||
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
|
var allMerchItems = await _unitOfWork.CatalogItems.FindAsync(
|
||||||
i => i.IsMerchandise && i.IsActive, false, i => i.Category);
|
i => i.CompanyId == companyId && i.IsMerchandise && i.IsActive, false, i => i.Category);
|
||||||
var merchItems = allMerchItems
|
var merchItems = allMerchItems
|
||||||
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
|
.OrderBy(i => i.Category.Name).ThenBy(i => i.DisplayOrder).ThenBy(i => i.Name)
|
||||||
.Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
|
.Select(i => new { i.Id, i.Name, i.SKU, CategoryName = i.Category.Name, i.DefaultPrice, i.RevenueAccountId })
|
||||||
@@ -2457,7 +2467,8 @@ public class InvoicesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task PopulateBankAccountsAsync()
|
private async Task PopulateBankAccountsAsync()
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
|
||||||
&& (a.AccountSubType == AccountSubType.Cash ||
|
&& (a.AccountSubType == AccountSubType.Cash ||
|
||||||
a.AccountSubType == AccountSubType.Checking ||
|
a.AccountSubType == AccountSubType.Checking ||
|
||||||
a.AccountSubType == AccountSubType.Savings));
|
a.AccountSubType == AccountSubType.Savings));
|
||||||
@@ -2472,7 +2483,7 @@ public class InvoicesController : Controller
|
|||||||
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
|
private async Task<int?> GetCheckingAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && (a.AccountSubType == AccountSubType.Checking
|
a => a.CompanyId == companyId && a.IsActive && (a.AccountSubType == AccountSubType.Checking
|
||||||
|| a.AccountSubType == AccountSubType.Cash));
|
|| a.AccountSubType == AccountSubType.Cash));
|
||||||
return acct?.Id;
|
return acct?.Id;
|
||||||
}
|
}
|
||||||
@@ -2481,7 +2492,23 @@ public class InvoicesController : Controller
|
|||||||
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
|
private async Task<int?> GetCustomerDepositsAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && a.AccountNumber == "2300");
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2300");
|
||||||
|
return acct?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the Sales Returns & Allowances contra-revenue account (4960) for refunds.</summary>
|
||||||
|
private async Task<int?> GetSalesReturnsAccountIdAsync(int companyId)
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "4960");
|
||||||
|
return acct?.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Returns the Customer Credits liability account (2350) for store credit / credit memos.</summary>
|
||||||
|
private async Task<int?> GetCustomerCreditsAccountIdAsync(int companyId)
|
||||||
|
{
|
||||||
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2350");
|
||||||
return acct?.Id;
|
return acct?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2489,7 +2516,7 @@ public class InvoicesController : Controller
|
|||||||
private async Task<int?> GetArAccountIdAsync(int companyId)
|
private async Task<int?> GetArAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(
|
var accounts = await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
|
a => a.CompanyId == companyId && a.IsActive && a.AccountSubType == AccountSubType.AccountsReceivable);
|
||||||
return accounts.FirstOrDefault()?.Id;
|
return accounts.FirstOrDefault()?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2500,7 +2527,7 @@ public class InvoicesController : Controller
|
|||||||
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
|
private async Task<int?> GetBadDebtAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var expenses = await _unitOfWork.Accounts.FindAsync(
|
var expenses = await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive && a.AccountType == AccountType.Expense);
|
a => a.CompanyId == companyId && a.IsActive && a.AccountType == AccountType.Expense);
|
||||||
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|
return expenses.FirstOrDefault(a => a.Name.Contains("bad", StringComparison.OrdinalIgnoreCase)
|
||||||
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
|
|| a.Name.Contains("debt", StringComparison.OrdinalIgnoreCase))?.Id
|
||||||
?? expenses.FirstOrDefault()?.Id;
|
?? expenses.FirstOrDefault()?.Id;
|
||||||
@@ -2531,9 +2558,9 @@ public class InvoicesController : Controller
|
|||||||
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
|
private async Task<int?> ResolveSalesTaxAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var taxAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountNumber == "2200" && a.IsActive);
|
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive);
|
||||||
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
taxAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
|
a => a.CompanyId == companyId && a.AccountType == AccountType.Liability && a.IsActive && a.Name.ToLower().Contains("tax"));
|
||||||
return taxAccount?.Id;
|
return taxAccount?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2545,9 +2572,9 @@ public class InvoicesController : Controller
|
|||||||
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
|
private async Task<int?> GetSalesDiscountAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var discountAccount = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountNumber == "4950" && a.IsActive);
|
a => a.CompanyId == companyId && a.AccountNumber == "4950" && a.IsActive);
|
||||||
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
discountAccount ??= await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
|
a => a.CompanyId == companyId && a.AccountType == AccountType.Revenue && a.IsActive && a.Name.ToLower().Contains("discount"));
|
||||||
return discountAccount?.Id;
|
return discountAccount?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2555,7 +2582,7 @@ public class InvoicesController : Controller
|
|||||||
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
private async Task<int?> GetGcLiabilityAccountIdAsync(int companyId)
|
||||||
{
|
{
|
||||||
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
var acct = await _unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
a => a.IsActive && a.AccountNumber == "2500");
|
a => a.CompanyId == companyId && a.IsActive && a.AccountNumber == "2500");
|
||||||
return acct?.Id;
|
return acct?.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2663,24 +2690,32 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// GL: store credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
|
||||||
|
// / CR Customer Credits (2350). The liability is relieved when the credit memo is applied.
|
||||||
|
var scDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
|
||||||
|
var scCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
|
||||||
|
await _accountBalanceService.DebitAsync(scDiscountAcctId, dto.Amount);
|
||||||
|
await _accountBalanceService.CreditAsync(scCustomerCreditsAcctId, dto.Amount);
|
||||||
|
|
||||||
TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
|
TempData["Success"] = $"Refund of {dto.Amount:C} applied as store credit. Credit memo {memoNumber} created.";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Adjust customer AR balance — they're owed money back
|
// "Reverse the sale": a cash refund contra's the original sale instead of re-opening AR.
|
||||||
if (invoice.Customer != null)
|
// GL: DR Sales Returns (revenue portion) + DR Sales Tax Payable (tax portion) / CR Bank.
|
||||||
{
|
// Customer AR balance is intentionally left unchanged — the invoice stays paid and the
|
||||||
invoice.Customer.CurrentBalance -= dto.Amount;
|
// sale is reversed via the contra accounts. The split is centralised in RefundAllocation
|
||||||
await _unitOfWork.Customers.UpdateAsync(invoice.Customer);
|
// so LedgerService and FinancialReportService recompute the same way.
|
||||||
}
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
// GL: DR AR (un-collects the payment) / CR Bank (cash leaves).
|
var (returnsPortion, taxPortion) = RefundAllocation.Split(dto.Amount, invoice.TaxAmount, invoice.Total);
|
||||||
// Mirrors how FinancialReportService accounts for refunds:
|
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(companyId);
|
||||||
// arTotalCredits -= refundTotal; refundsByAcct credits the bank account.
|
var salesTaxAccountId = invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(companyId);
|
||||||
var arAccountId = await GetArAccountIdAsync(companyId);
|
|
||||||
await _accountBalanceService.DebitAsync(arAccountId, dto.Amount);
|
await _accountBalanceService.DebitAsync(salesReturnsAccountId, returnsPortion);
|
||||||
|
if (taxPortion > 0)
|
||||||
|
await _accountBalanceService.DebitAsync(salesTaxAccountId, taxPortion);
|
||||||
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
|
await _accountBalanceService.CreditAsync(dto.DepositAccountId, dto.Amount);
|
||||||
|
|
||||||
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
|
TempData["Success"] = $"Refund of {dto.Amount:C} recorded successfully. Please issue the refund manually.";
|
||||||
@@ -2731,12 +2766,14 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
if (refund.RefundMethod == PaymentMethod.StoreCredit)
|
if (refund.RefundMethod == PaymentMethod.StoreCredit)
|
||||||
{
|
{
|
||||||
// Cancel the linked CreditMemo and reverse the CreditBalance
|
// Cancel the linked CreditMemo and reverse the unapplied store-credit remainder.
|
||||||
|
decimal creditReversed = refund.Amount;
|
||||||
if (refund.CreditMemoId.HasValue)
|
if (refund.CreditMemoId.HasValue)
|
||||||
{
|
{
|
||||||
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
|
var memo = await _unitOfWork.CreditMemos.GetByIdAsync(refund.CreditMemoId.Value);
|
||||||
if (memo != null && memo.Status == CreditMemoStatus.Active)
|
if (memo != null && memo.Status == CreditMemoStatus.Active)
|
||||||
{
|
{
|
||||||
|
creditReversed = memo.Amount - memo.AmountApplied; // only the unapplied remainder
|
||||||
memo.Status = CreditMemoStatus.Voided;
|
memo.Status = CreditMemoStatus.Voided;
|
||||||
memo.UpdatedAt = DateTime.UtcNow;
|
memo.UpdatedAt = DateTime.UtcNow;
|
||||||
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
await _unitOfWork.CreditMemos.UpdateAsync(memo);
|
||||||
@@ -2745,22 +2782,30 @@ public class InvoicesController : Controller
|
|||||||
|
|
||||||
if (customer != null)
|
if (customer != null)
|
||||||
{
|
{
|
||||||
customer.CreditBalance -= refund.Amount;
|
customer.CreditBalance = Math.Max(0, customer.CreditBalance - creditReversed);
|
||||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
await _unitOfWork.Customers.UpdateAsync(customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GL: reverse the unapplied store-credit issuance — DR Customer Credits / CR Sales Discounts.
|
||||||
|
if (creditReversed > 0)
|
||||||
|
{
|
||||||
|
var ccAcctId = await GetCustomerCreditsAccountIdAsync(refund.Invoice.CompanyId);
|
||||||
|
var sdAcctId = await GetSalesDiscountAccountIdAsync(refund.Invoice.CompanyId);
|
||||||
|
await _accountBalanceService.DebitAsync(ccAcctId, creditReversed);
|
||||||
|
await _accountBalanceService.CreditAsync(sdAcctId, creditReversed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Reverse the AR balance adjustment
|
// Reverse the "reverse the sale" posting: CR Sales Returns + CR Sales Tax Payable / DR Bank.
|
||||||
if (customer != null)
|
// The customer's AR balance was not touched when the refund was issued, so it is not touched here.
|
||||||
{
|
var (returnsPortion, taxPortion) = RefundAllocation.Split(refund.Amount, refund.Invoice.TaxAmount, refund.Invoice.Total);
|
||||||
customer.CurrentBalance += refund.Amount;
|
var salesReturnsAccountId = await GetSalesReturnsAccountIdAsync(refund.Invoice.CompanyId);
|
||||||
await _unitOfWork.Customers.UpdateAsync(customer);
|
var salesTaxAccountId = refund.Invoice.SalesTaxAccountId ?? await ResolveSalesTaxAccountIdAsync(refund.Invoice.CompanyId);
|
||||||
}
|
|
||||||
|
|
||||||
// GL reversal: CR AR / DR Bank — mirrors the DR AR / CR Bank posted in IssueRefund.
|
await _accountBalanceService.CreditAsync(salesReturnsAccountId, returnsPortion);
|
||||||
var arAccountId = await GetArAccountIdAsync(refund.Invoice.CompanyId);
|
if (taxPortion > 0)
|
||||||
await _accountBalanceService.CreditAsync(arAccountId, refund.Amount);
|
await _accountBalanceService.CreditAsync(salesTaxAccountId, taxPortion);
|
||||||
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
|
await _accountBalanceService.DebitAsync(refund.DepositAccountId, refund.Amount);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2823,6 +2868,14 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// GL: the credit is a liability owed to the customer — DR Sales Discounts (contra-revenue)
|
||||||
|
// / CR Customer Credits (2350). Relieved when the memo is applied to an invoice.
|
||||||
|
var cmDiscountAcctId = await GetSalesDiscountAccountIdAsync(companyId);
|
||||||
|
var cmCustomerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(companyId);
|
||||||
|
await _accountBalanceService.DebitAsync(cmDiscountAcctId, dto.Amount);
|
||||||
|
await _accountBalanceService.CreditAsync(cmCustomerCreditsAcctId, dto.Amount);
|
||||||
|
|
||||||
TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer.";
|
TempData["Success"] = $"Credit memo {memoNumber} for {dto.Amount:C} issued to customer.";
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -2909,9 +2962,11 @@ public class InvoicesController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply.
|
// GL: DR Sales Discounts 4950 / CR AR — same as CreditMemosController.Apply.
|
||||||
|
// GL: applying the credit relieves the liability — DR Customer Credits (2350) / CR AR.
|
||||||
|
// (The contra-revenue was already recognized as Sales Discounts when the credit was issued.)
|
||||||
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
var arAccountId = await GetArAccountIdAsync(invoice.CompanyId);
|
||||||
var discountAcctId = await GetSalesDiscountAccountIdAsync(invoice.CompanyId);
|
var customerCreditsAcctId = await GetCustomerCreditsAccountIdAsync(invoice.CompanyId);
|
||||||
await _accountBalanceService.DebitAsync(discountAcctId, applyAmount);
|
await _accountBalanceService.DebitAsync(customerCreditsAcctId, applyAmount);
|
||||||
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
|
await _accountBalanceService.CreditAsync(arAccountId, applyAmount);
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
@@ -2954,6 +3009,15 @@ public class InvoicesController : Controller
|
|||||||
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
await _unitOfWork.Customers.UpdateAsync(memo.Customer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GL: reverse the unapplied issuance — DR Customer Credits (2350) / CR Sales Discounts.
|
||||||
|
if (remaining > 0)
|
||||||
|
{
|
||||||
|
var ccAcctId = await GetCustomerCreditsAccountIdAsync(memo.CompanyId);
|
||||||
|
var sdAcctId = await GetSalesDiscountAccountIdAsync(memo.CompanyId);
|
||||||
|
await _accountBalanceService.DebitAsync(ccAcctId, remaining);
|
||||||
|
await _accountBalanceService.CreditAsync(sdAcctId, remaining);
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
await _unitOfWork.CompleteAsync();
|
||||||
TempData["Success"] = "Credit memo voided.";
|
TempData["Success"] = "Credit memo voided.";
|
||||||
return RedirectToAction(nameof(Details), new { id = invoiceId });
|
return RedirectToAction(nameof(Details), new { id = invoiceId });
|
||||||
|
|||||||
@@ -213,24 +213,29 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Pill badge counts — always global (not scoped to current filter/page)
|
// Pill badge counts — always global (not scoped to current filter/page)
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync();
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId);
|
||||||
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
j.CompanyId == companyId
|
||||||
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
j.DueDate < today
|
j.CompanyId == companyId
|
||||||
|
&& j.DueDate < today
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered
|
||||||
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
&& j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled);
|
||||||
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
j.CompanyId == companyId &&
|
||||||
|
(j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed
|
||||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup
|
||||||
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered);
|
|| j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered));
|
||||||
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j =>
|
||||||
j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
j.CompanyId == companyId
|
||||||
|
&& j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup);
|
||||||
|
|
||||||
// Set ViewBag for sorting
|
// Set ViewBag for sorting
|
||||||
ViewBag.SearchTerm = searchTerm;
|
ViewBag.SearchTerm = searchTerm;
|
||||||
@@ -446,6 +451,9 @@ public class JobsController : Controller
|
|||||||
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
|
ViewBag.JobInvoiceStatus = jobInvoice?.Status;
|
||||||
ViewBag.JobVoidedInvoices = voidedInvoices;
|
ViewBag.JobVoidedInvoices = voidedInvoices;
|
||||||
|
|
||||||
|
// Bank/asset accounts the deposit can land in (deposit modal dropdown)
|
||||||
|
ViewBag.DepositAccounts = await AccountingDropdownHelper.LoadDepositAccountsAsync(_unitOfWork, companyId);
|
||||||
|
|
||||||
// Workers dropdown for inline assignment
|
// Workers dropdown for inline assignment
|
||||||
await PopulateWorkersDropdown();
|
await PopulateWorkersDropdown();
|
||||||
|
|
||||||
@@ -2168,10 +2176,12 @@ public class JobsController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var today = date?.Date ?? DateTime.Today;
|
var today = date?.Date ?? DateTime.Today;
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
|
// Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel)
|
||||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||||
s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
s.CompanyId == companyId
|
||||||
|
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted
|
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted
|
||||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved);
|
&& s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved);
|
||||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
@@ -2181,7 +2191,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
// Get existing priority records for today
|
// Get existing priority records for today
|
||||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||||
.FindAsync(p => p.ScheduledDate.Date == today);
|
.FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
|
||||||
|
|
||||||
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
||||||
|
|
||||||
@@ -2278,7 +2288,8 @@ public class JobsController : Controller
|
|||||||
if (!companyId.HasValue) return RedirectToAction(nameof(Index));
|
if (!companyId.HasValue) return RedirectToAction(nameof(Index));
|
||||||
|
|
||||||
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s =>
|
||||||
!s.IsTerminalStatus
|
s.CompanyId == companyId.Value
|
||||||
|
&& !s.IsTerminalStatus
|
||||||
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
&& s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled
|
||||||
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered);
|
&& s.StatusCode != AppConstants.StatusCodes.Job.Delivered);
|
||||||
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList();
|
||||||
@@ -2997,13 +3008,17 @@ public class JobsController : Controller
|
|||||||
inventoryItem.QuantityOnHand -= deductNow;
|
inventoryItem.QuantityOnHand -= deductNow;
|
||||||
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
await _unitOfWork.InventoryItems.UpdateAsync(inventoryItem);
|
||||||
|
|
||||||
|
// Record the consumption at the effective (weighted-average) unit cost so the
|
||||||
|
// transaction's TotalCost equals the COGS actually posted — the GL recompute
|
||||||
|
// reads TotalCost to reproduce the DR COGS / CR Inventory entry.
|
||||||
|
var effectiveUnitCost = inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost;
|
||||||
var transaction = new InventoryTransaction
|
var transaction = new InventoryTransaction
|
||||||
{
|
{
|
||||||
InventoryItemId = inventoryItem.Id,
|
InventoryItemId = inventoryItem.Id,
|
||||||
TransactionType = InventoryTransactionType.JobUsage,
|
TransactionType = InventoryTransactionType.JobUsage,
|
||||||
Quantity = -deductNow,
|
Quantity = -deductNow,
|
||||||
UnitCost = inventoryItem.UnitCost,
|
UnitCost = effectiveUnitCost,
|
||||||
TotalCost = inventoryItem.UnitCost * deductNow,
|
TotalCost = effectiveUnitCost * deductNow,
|
||||||
TransactionDate = DateTime.UtcNow,
|
TransactionDate = DateTime.UtcNow,
|
||||||
JobId = job.Id,
|
JobId = job.Id,
|
||||||
Reference = job.JobNumber,
|
Reference = job.JobNumber,
|
||||||
@@ -3015,7 +3030,7 @@ public class JobsController : Controller
|
|||||||
|
|
||||||
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
if (inventoryItem.CogsAccountId.HasValue && inventoryItem.InventoryAccountId.HasValue)
|
||||||
{
|
{
|
||||||
var cost = deductNow * (inventoryItem.AverageCost > 0 ? inventoryItem.AverageCost : inventoryItem.UnitCost);
|
var cost = transaction.TotalCost;
|
||||||
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
await _accountBalanceService.DebitAsync(inventoryItem.CogsAccountId, cost);
|
||||||
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
await _accountBalanceService.CreditAsync(inventoryItem.InventoryAccountId, cost);
|
||||||
}
|
}
|
||||||
@@ -3492,7 +3507,8 @@ public class JobsController : Controller
|
|||||||
efficiency = i.TransferEfficiency ?? 65m,
|
efficiency = i.TransferEfficiency ?? 65m,
|
||||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||||
categoryName = i.InventoryCategory!.DisplayName,
|
categoryName = i.InventoryCategory!.DisplayName,
|
||||||
costPerLb = i.UnitCost,
|
// Quote at the current catalog price when linked; fall back to their cost otherwise.
|
||||||
|
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
|
||||||
colorName = i.ColorName ?? i.Name,
|
colorName = i.ColorName ?? i.Name,
|
||||||
colorCode = i.ColorCode ?? "",
|
colorCode = i.ColorCode ?? "",
|
||||||
isIncoming = i.IsIncoming
|
isIncoming = i.IsIncoming
|
||||||
|
|||||||
@@ -57,13 +57,14 @@ public class JobsPriorityController : Controller
|
|||||||
public async Task<IActionResult> Index(DateTime? date)
|
public async Task<IActionResult> Index(DateTime? date)
|
||||||
{
|
{
|
||||||
var today = date?.Date ?? DateTime.Today;
|
var today = date?.Date ?? DateTime.Today;
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Get all jobs scheduled for today with related data
|
// Get all jobs scheduled for today with related data
|
||||||
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
|
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
|
||||||
|
|
||||||
// Get existing priority records for today
|
// Get existing priority records for today
|
||||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||||
.FindAsync(p => p.ScheduledDate.Date == today);
|
.FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today);
|
||||||
|
|
||||||
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
||||||
|
|
||||||
@@ -90,7 +91,6 @@ public class JobsPriorityController : Controller
|
|||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Get priorities and workers for modal options
|
// Get priorities and workers for modal options
|
||||||
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
|
||||||
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
|
||||||
var workers = await _userManager.Users
|
var workers = await _userManager.Users
|
||||||
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
|
||||||
@@ -99,7 +99,7 @@ public class JobsPriorityController : Controller
|
|||||||
|
|
||||||
// Get maintenance records scheduled for today (Scheduled or InProgress)
|
// Get maintenance records scheduled for today (Scheduled or InProgress)
|
||||||
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
|
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
|
||||||
m => m.ScheduledDate.Date == today &&
|
m => m.CompanyId == companyId && m.ScheduledDate.Date == today &&
|
||||||
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
|
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
|
||||||
false,
|
false,
|
||||||
m => m.Equipment, m => m.AssignedUser))
|
m => m.Equipment, m => m.AssignedUser))
|
||||||
@@ -169,10 +169,11 @@ public class JobsPriorityController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
var today = DateTime.Today;
|
var today = DateTime.Today;
|
||||||
|
var cid = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Get all existing priority records for today
|
// Get all existing priority records for today
|
||||||
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
var existingPriorities = await _unitOfWork.JobDailyPriorities
|
||||||
.FindAsync(p => p.ScheduledDate.Date == today);
|
.FindAsync(p => p.CompanyId == cid && p.ScheduledDate.Date == today);
|
||||||
|
|
||||||
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
var priorityDict = existingPriorities.ToDictionary(p => p.JobId);
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ public class JournalEntriesController : Controller
|
|||||||
|
|
||||||
// Load account names for lines
|
// Load account names for lines
|
||||||
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList();
|
var accountIds = je.Lines.Select(l => l.AccountId).Distinct().ToList();
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == je.CompanyId && accountIds.Contains(a.Id));
|
||||||
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
||||||
|
|
||||||
// Reversal metadata
|
// Reversal metadata
|
||||||
@@ -196,6 +196,113 @@ public class JournalEntriesController : Controller
|
|||||||
return RedirectToAction(nameof(Details), new { id });
|
return RedirectToAction(nameof(Details), new { id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sales Tax Remittance ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Form to record a sales tax payment to the tax authority. Shows the current Sales Tax Payable
|
||||||
|
/// (2200) liability and a bank-account picker. Relieves the liability that invoices accumulate.
|
||||||
|
/// </summary>
|
||||||
|
// GET: /JournalEntries/SalesTaxPayment
|
||||||
|
public async Task<IActionResult> SalesTaxPayment()
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
|
||||||
|
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
|
||||||
|
ViewBag.TaxLiability = taxAcct?.CurrentBalance ?? 0m;
|
||||||
|
ViewBag.TaxAccountFound = taxAcct != null;
|
||||||
|
|
||||||
|
var banks = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive
|
||||||
|
&& (a.AccountSubType == AccountSubType.Checking
|
||||||
|
|| a.AccountSubType == AccountSubType.Savings
|
||||||
|
|| a.AccountSubType == AccountSubType.Cash));
|
||||||
|
ViewBag.BankAccounts = banks.OrderBy(a => a.AccountNumber)
|
||||||
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString())).ToList();
|
||||||
|
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a sales tax remittance as a posted journal entry: DR Sales Tax Payable (2200) / CR the
|
||||||
|
/// chosen bank account. Honors the period lock. The reporting already accounts for posted JE lines,
|
||||||
|
/// so this is all that's needed to relieve the liability.
|
||||||
|
/// </summary>
|
||||||
|
// POST: /JournalEntries/SalesTaxPayment
|
||||||
|
[HttpPost]
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> SalesTaxPayment(decimal amount, DateTime paymentDate, int bankAccountId, string? reference)
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return RedirectToAction("Landing", "Reports");
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
|
if (amount <= 0)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Enter a payment amount greater than zero.";
|
||||||
|
return RedirectToAction(nameof(SalesTaxPayment));
|
||||||
|
}
|
||||||
|
|
||||||
|
var taxAcct = (await _unitOfWork.Accounts.FindAsync(
|
||||||
|
a => a.CompanyId == companyId && a.AccountNumber == "2200" && a.IsActive)).FirstOrDefault();
|
||||||
|
if (taxAcct == null)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "No active Sales Tax Payable (2200) account found in your chart of accounts.";
|
||||||
|
return RedirectToAction(nameof(SalesTaxPayment));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't let a remittance exceed the outstanding liability — overpaying would push Sales Tax
|
||||||
|
// Payable into an abnormal (debit) balance. The 0.005 tolerance absorbs decimal rounding.
|
||||||
|
if (amount > taxAcct.CurrentBalance + 0.005m)
|
||||||
|
{
|
||||||
|
TempData["Error"] = $"Payment of {amount:C} exceeds the Sales Tax Payable balance of {taxAcct.CurrentBalance:C}. Enter an amount up to the outstanding liability.";
|
||||||
|
return RedirectToAction(nameof(SalesTaxPayment));
|
||||||
|
}
|
||||||
|
|
||||||
|
var bankAcct = await _unitOfWork.Accounts.GetByIdAsync(bankAccountId);
|
||||||
|
if (bankAcct == null || bankAcct.CompanyId != companyId)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "Select a valid bank account to pay from.";
|
||||||
|
return RedirectToAction(nameof(SalesTaxPayment));
|
||||||
|
}
|
||||||
|
|
||||||
|
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
|
||||||
|
if (Web.Helpers.AccountingPeriodValidator.IsLocked(paymentDate, company?.BookLockedThrough))
|
||||||
|
{
|
||||||
|
TempData["Error"] = Web.Helpers.AccountingPeriodValidator.LockedMessage(company!.BookLockedThrough);
|
||||||
|
return RedirectToAction(nameof(SalesTaxPayment));
|
||||||
|
}
|
||||||
|
|
||||||
|
var entryNumber = await GenerateEntryNumberAsync(companyId);
|
||||||
|
var entry = new JournalEntry
|
||||||
|
{
|
||||||
|
EntryNumber = entryNumber,
|
||||||
|
EntryDate = paymentDate,
|
||||||
|
Reference = string.IsNullOrWhiteSpace(reference) ? "Sales tax remittance" : reference.Trim(),
|
||||||
|
Description = $"Sales tax remittance — {amount:C}",
|
||||||
|
Status = JournalEntryStatus.Posted,
|
||||||
|
PostedAt = DateTime.UtcNow,
|
||||||
|
PostedBy = User.Identity?.Name,
|
||||||
|
CompanyId = companyId,
|
||||||
|
Lines = new List<JournalEntryLine>
|
||||||
|
{
|
||||||
|
new() { AccountId = taxAcct.Id, DebitAmount = amount, CreditAmount = 0, Description = "Sales tax paid to authority", LineOrder = 0, CompanyId = companyId },
|
||||||
|
new() { AccountId = bankAcct.Id, DebitAmount = 0, CreditAmount = amount, Description = $"Paid from {bankAcct.Name}", LineOrder = 1, CompanyId = companyId }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await _unitOfWork.ExecuteInTransactionAsync(async () =>
|
||||||
|
{
|
||||||
|
await _unitOfWork.JournalEntries.AddAsync(entry);
|
||||||
|
await _accountBalanceService.DebitAsync(taxAcct.Id, amount); // reduce the liability
|
||||||
|
await _accountBalanceService.CreditAsync(bankAcct.Id, amount); // cash leaves the bank
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
TempData["Success"] = $"Recorded sales tax remittance of {amount:C} ({entryNumber}).";
|
||||||
|
return RedirectToAction(nameof(Details), new { id = entry.Id });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reverse ──────────────────────────────────────────────────────────────
|
// ── Reverse ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
@@ -367,7 +474,8 @@ public class JournalEntriesController : Controller
|
|||||||
|
|
||||||
private async Task PopulateAccountDropdownAsync()
|
private async Task PopulateAccountDropdownAsync()
|
||||||
{
|
{
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||||
ViewBag.AccountSelectList = accounts
|
ViewBag.AccountSelectList = accounts
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.Select(a => new SelectListItem
|
.Select(a => new SelectListItem
|
||||||
|
|||||||
@@ -582,7 +582,9 @@ public class KioskController : Controller
|
|||||||
[Authorize]
|
[Authorize]
|
||||||
public async Task<IActionResult> Intakes(string? filter)
|
public async Task<IActionResult> Intakes(string? filter)
|
||||||
{
|
{
|
||||||
var sessions = await _unitOfWork.KioskSessions.GetAllAsync(false,
|
var companyId = GetCurrentCompanyId();
|
||||||
|
var sessions = await _unitOfWork.KioskSessions.FindAsync(
|
||||||
|
s => s.CompanyId == companyId, false,
|
||||||
s => s.LinkedCustomer,
|
s => s.LinkedCustomer,
|
||||||
s => s.LinkedJob);
|
s => s.LinkedJob);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ using PowderCoating.Web.Hubs;
|
|||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
// Oven batch scheduling is shop-floor job management — gated to CanManageJobs so
|
||||||
|
// low-privilege roles can't create/modify/delete batches. (Audit #3, 2026-06-20.)
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||||
public class OvenSchedulerController : Controller
|
public class OvenSchedulerController : Controller
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
@@ -61,16 +63,17 @@ public class OvenSchedulerController : Controller
|
|||||||
public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput")
|
public async Task<IActionResult> Index(DateTime? date, string goal = "maximize_throughput")
|
||||||
{
|
{
|
||||||
var scheduledDate = date?.Date ?? DateTime.Today;
|
var scheduledDate = date?.Date ?? DateTime.Today;
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
|
||||||
// Load active Named Ovens — filter IsActive at database level
|
// Load active Named Ovens — filter IsActive at database level
|
||||||
var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive))
|
var ovenCosts = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == companyId && o.IsActive))
|
||||||
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
|
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// Load batches for the selected date — filter at database level with includes
|
// Load batches for the selected date — filter at database level with includes
|
||||||
var scheduledDateEnd = scheduledDate.AddDays(1);
|
var scheduledDateEnd = scheduledDate.AddDays(1);
|
||||||
var batches = (await _unitOfWork.OvenBatches.FindAsync(
|
var batches = (await _unitOfWork.OvenBatches.FindAsync(
|
||||||
b => b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd
|
b => b.CompanyId == companyId && b.ScheduledDate >= scheduledDate && b.ScheduledDate < scheduledDateEnd
|
||||||
&& b.Status != OvenBatchStatus.Cancelled,
|
&& b.Status != OvenBatchStatus.Cancelled,
|
||||||
false,
|
false,
|
||||||
b => b.OvenCost, b => b.Items))
|
b => b.OvenCost, b => b.Items))
|
||||||
@@ -98,7 +101,7 @@ public class OvenSchedulerController : Controller
|
|||||||
|
|
||||||
// Load jobs in the queue — filter by status at database level
|
// Load jobs in the queue — filter by status at database level
|
||||||
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
|
var queueJobs = (await _unitOfWork.Jobs.FindAsync(
|
||||||
j => j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode),
|
j => j.CompanyId == companyId && j.JobStatus != null && QueueableStatuses.Contains(j.JobStatus.StatusCode),
|
||||||
false,
|
false,
|
||||||
j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems))
|
j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.JobItems))
|
||||||
.ToList();
|
.ToList();
|
||||||
@@ -126,14 +129,14 @@ public class OvenSchedulerController : Controller
|
|||||||
|
|
||||||
// Determine which coats are already scheduled — filter out removed/cancelled at database level
|
// Determine which coats are already scheduled — filter out removed/cancelled at database level
|
||||||
var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync(
|
var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync(
|
||||||
i => i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled,
|
i => i.CompanyId == companyId && i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled,
|
||||||
false,
|
false,
|
||||||
i => i.Batch))
|
i => i.Batch))
|
||||||
.Select(i => i.JobItemCoatId)
|
.Select(i => i.JobItemCoatId)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
|
|
||||||
// Get company defaults
|
// Get company defaults
|
||||||
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => true);
|
var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId);
|
||||||
var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45;
|
var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45;
|
||||||
|
|
||||||
// Build the view model
|
// Build the view model
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using PowderCoating.Application.Constants;
|
||||||
using PowderCoating.Application.DTOs.Common;
|
using PowderCoating.Application.DTOs.Common;
|
||||||
using PowderCoating.Application.DTOs.Inventory;
|
using PowderCoating.Application.DTOs.Inventory;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
@@ -17,17 +18,28 @@ public class PowderCatalogController : Controller
|
|||||||
{
|
{
|
||||||
private const decimal DefaultTransferEfficiency = 65m;
|
private const decimal DefaultTransferEfficiency = 65m;
|
||||||
|
|
||||||
|
private const string JsonImportSource = "Manual JSON Import";
|
||||||
|
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
private readonly IInventoryAiLookupService _aiLookupService;
|
private readonly IInventoryAiLookupService _aiLookupService;
|
||||||
|
private readonly IColumbiaCatalogSyncService _columbiaSyncService;
|
||||||
|
private readonly IPowderCatalogUpsertService _upsertService;
|
||||||
|
private readonly IPlatformSettingsService _platformSettings;
|
||||||
private readonly ILogger<PowderCatalogController> _logger;
|
private readonly ILogger<PowderCatalogController> _logger;
|
||||||
|
|
||||||
public PowderCatalogController(
|
public PowderCatalogController(
|
||||||
IUnitOfWork unitOfWork,
|
IUnitOfWork unitOfWork,
|
||||||
IInventoryAiLookupService aiLookupService,
|
IInventoryAiLookupService aiLookupService,
|
||||||
|
IColumbiaCatalogSyncService columbiaSyncService,
|
||||||
|
IPowderCatalogUpsertService upsertService,
|
||||||
|
IPlatformSettingsService platformSettings,
|
||||||
ILogger<PowderCatalogController> logger)
|
ILogger<PowderCatalogController> logger)
|
||||||
{
|
{
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
_aiLookupService = aiLookupService;
|
_aiLookupService = aiLookupService;
|
||||||
|
_columbiaSyncService = columbiaSyncService;
|
||||||
|
_upsertService = upsertService;
|
||||||
|
_platformSettings = platformSettings;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +147,11 @@ public class PowderCatalogController : Controller
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Columbia sync status for the admin panel (last run + master switch).
|
||||||
|
ViewBag.ColumbiaSyncEnabled = await _platformSettings.GetBoolAsync(ColumbiaIntegrationConstants.SettingEnabled);
|
||||||
|
ViewBag.ColumbiaLastSyncedAt = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt);
|
||||||
|
ViewBag.ColumbiaLastResult = await _platformSettings.GetAsync(ColumbiaIntegrationConstants.SettingLastResult);
|
||||||
|
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +439,78 @@ public class PowderCatalogController : Controller
|
|||||||
return Json(results);
|
return Json(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manually triggers a full Columbia Coatings catalog sync (SuperAdmin only). Bypasses the
|
||||||
|
/// scheduled interval. Reports the run outcome via TempData on the catalog index.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> SyncColumbia(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await _columbiaSyncService.RunSyncAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (result.Success)
|
||||||
|
TempData["Success"] = $"Columbia sync complete - {result.Summary}";
|
||||||
|
else
|
||||||
|
TempData["Error"] = $"Columbia sync failed: {result.ErrorMessage}";
|
||||||
|
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Right-to-delete: removes every catalog record sourced from the Columbia Coatings API
|
||||||
|
/// (regardless of derived manufacturer, since PPG/KP products were served through that feed)
|
||||||
|
/// and nulls any inventory links to them across all tenants. The shops' own inventory stock
|
||||||
|
/// records survive — only the catalog link and discontinued badge are lost. SuperAdmin only.
|
||||||
|
/// </summary>
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> PurgeColumbiaData()
|
||||||
|
{
|
||||||
|
var sourced = (await _unitOfWork.PowderCatalog.FindAsync(
|
||||||
|
p => p.Source == ColumbiaIntegrationConstants.SourceName)).ToList();
|
||||||
|
|
||||||
|
if (sourced.Count == 0)
|
||||||
|
{
|
||||||
|
TempData["Error"] = "There is no Columbia Coatings API data to remove.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
var ids = sourced.Select(p => p.Id).ToList();
|
||||||
|
|
||||||
|
// Null the inventory links across ALL tenants (platform-level purge). A tenant's stock
|
||||||
|
// record is their data and must survive — it keeps its add-time snapshot, losing only the
|
||||||
|
// live catalog link.
|
||||||
|
var linked = (await _unitOfWork.InventoryItems.FindAsync(
|
||||||
|
i => i.PowderCatalogItemId.HasValue && ids.Contains(i.PowderCatalogItemId.Value),
|
||||||
|
ignoreQueryFilters: true)).ToList();
|
||||||
|
foreach (var inv in linked)
|
||||||
|
{
|
||||||
|
inv.PowderCatalogItemId = null;
|
||||||
|
await _unitOfWork.InventoryItems.UpdateAsync(inv);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var p in sourced)
|
||||||
|
await _unitOfWork.PowderCatalog.DeleteAsync(p);
|
||||||
|
await _unitOfWork.CompleteAsync();
|
||||||
|
|
||||||
|
// Reset sync tracking so the admin panel reflects the purge.
|
||||||
|
await _platformSettings.SetAsync(ColumbiaIntegrationConstants.SettingLastSyncedAt, null, "Columbia Purge");
|
||||||
|
await _platformSettings.SetAsync(
|
||||||
|
ColumbiaIntegrationConstants.SettingLastResult,
|
||||||
|
$"Purged {sourced.Count:N0} records on {DateTime.UtcNow:yyyy-MM-dd}",
|
||||||
|
"Columbia Purge");
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Columbia data purge: deleted {Count} catalog records, unlinked {Linked} inventory items.",
|
||||||
|
sourced.Count, linked.Count);
|
||||||
|
|
||||||
|
TempData["Success"] =
|
||||||
|
$"Removed {sourced.Count:N0} Columbia Coatings catalog record(s) and unlinked " +
|
||||||
|
$"{linked.Count:N0} inventory item(s). Inventory stock was preserved.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
// Private helpers
|
// Private helpers
|
||||||
|
|
||||||
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
|
private async Task ApplyTdsCureFallbackAsync(InventoryAiLookupResult result, string? colorName)
|
||||||
@@ -449,13 +538,10 @@ public class PowderCatalogController : Controller
|
|||||||
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
|
return new PowderCatalogImportResult { Success = false, ErrorMessage = "JSON must have a top-level 'results' array." };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load existing records for this vendor into a lookup dictionary
|
// Map the scrape format to catalog items, then hand off to the shared upsert path (same
|
||||||
var existing = (await _unitOfWork.PowderCatalog.FindAsync(p => p.VendorName == vendorName))
|
// one the Columbia API sync uses) so there is a single insert/update/diff implementation.
|
||||||
.ToDictionary(p => p.Sku, StringComparer.OrdinalIgnoreCase);
|
var mapped = new List<PowderCatalogItem>();
|
||||||
|
int skipped = 0, errors = 0;
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
int inserted = 0, updated = 0, skipped = 0, errors = 0;
|
|
||||||
var toAdd = new List<PowderCatalogItem>();
|
|
||||||
|
|
||||||
foreach (var item in resultsEl.EnumerateArray())
|
foreach (var item in resultsEl.EnumerateArray())
|
||||||
{
|
{
|
||||||
@@ -469,49 +555,21 @@ public class PowderCatalogController : Controller
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawDesc = item.GetStringOrNull("description");
|
mapped.Add(new PowderCatalogItem
|
||||||
var cleanDesc = StripBoilerplate(rawDesc);
|
|
||||||
var unitPrice = ExtractBasePrice(item);
|
|
||||||
var priceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl)
|
|
||||||
? tiersEl.GetRawText()
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (existing.TryGetValue(sku, out var record))
|
|
||||||
{
|
|
||||||
record.ColorName = colorName;
|
|
||||||
record.Description = cleanDesc;
|
|
||||||
record.UnitPrice = unitPrice;
|
|
||||||
record.PriceTiersJson = priceTiersJson;
|
|
||||||
record.ImageUrl = item.GetStringOrNull("sample_image_url");
|
|
||||||
record.SdsUrl = item.GetStringOrNull("safety_data_sheet_url");
|
|
||||||
record.TdsUrl = item.GetStringOrNull("technical_data_sheet_url");
|
|
||||||
record.ApplicationGuideUrl = item.GetStringOrNull("application_guide_url");
|
|
||||||
record.ProductUrl = item.GetStringOrNull("product_url");
|
|
||||||
record.UpdatedAt = now;
|
|
||||||
record.LastSyncedAt = now;
|
|
||||||
await _unitOfWork.PowderCatalog.UpdateAsync(record);
|
|
||||||
updated++;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
toAdd.Add(new PowderCatalogItem
|
|
||||||
{
|
{
|
||||||
VendorName = vendorName,
|
VendorName = vendorName,
|
||||||
|
Source = JsonImportSource,
|
||||||
Sku = sku,
|
Sku = sku,
|
||||||
ColorName = colorName,
|
ColorName = colorName,
|
||||||
Description = cleanDesc,
|
Description = StripBoilerplate(item.GetStringOrNull("description")),
|
||||||
UnitPrice = unitPrice,
|
UnitPrice = ExtractBasePrice(item),
|
||||||
PriceTiersJson = priceTiersJson,
|
PriceTiersJson = item.TryGetProperty("price_tiers", out var tiersEl) ? tiersEl.GetRawText() : null,
|
||||||
ImageUrl = item.GetStringOrNull("sample_image_url"),
|
ImageUrl = item.GetStringOrNull("sample_image_url"),
|
||||||
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
|
SdsUrl = item.GetStringOrNull("safety_data_sheet_url"),
|
||||||
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
|
TdsUrl = item.GetStringOrNull("technical_data_sheet_url"),
|
||||||
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
|
ApplicationGuideUrl = item.GetStringOrNull("application_guide_url"),
|
||||||
ProductUrl = item.GetStringOrNull("product_url"),
|
ProductUrl = item.GetStringOrNull("product_url"),
|
||||||
CreatedAt = now,
|
|
||||||
LastSyncedAt = now
|
|
||||||
});
|
});
|
||||||
inserted++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -520,17 +578,14 @@ public class PowderCatalogController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toAdd.Any())
|
var upsert = await _upsertService.UpsertAsync(mapped, DateTime.UtcNow);
|
||||||
await _unitOfWork.PowderCatalog.AddRangeAsync(toAdd);
|
|
||||||
|
|
||||||
await _unitOfWork.CompleteAsync();
|
|
||||||
|
|
||||||
return new PowderCatalogImportResult
|
return new PowderCatalogImportResult
|
||||||
{
|
{
|
||||||
Success = true,
|
Success = true,
|
||||||
Inserted = inserted,
|
Inserted = upsert.Inserted,
|
||||||
Updated = updated,
|
Updated = upsert.Updated,
|
||||||
Skipped = skipped,
|
Skipped = skipped + upsert.Skipped,
|
||||||
Errors = errors
|
Errors = errors
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ public class PricingTiersController : Controller
|
|||||||
return View(dto);
|
return View(dto);
|
||||||
|
|
||||||
// Check for duplicate name
|
// Check for duplicate name
|
||||||
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.TierName == dto.TierName);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var existing = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId && t.TierName == dto.TierName);
|
||||||
if (existing.Any())
|
if (existing.Any())
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
||||||
@@ -111,8 +112,9 @@ public class PricingTiersController : Controller
|
|||||||
if (entity == null) return NotFound();
|
if (entity == null) return NotFound();
|
||||||
|
|
||||||
// Check for duplicate name (excluding this record)
|
// Check for duplicate name (excluding this record)
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var duplicate = await _unitOfWork.PricingTiers.FindAsync(
|
var duplicate = await _unitOfWork.PricingTiers.FindAsync(
|
||||||
t => t.TierName == dto.TierName && t.Id != dto.Id);
|
t => t.CompanyId == companyId && t.TierName == dto.TierName && t.Id != dto.Id);
|
||||||
if (duplicate.Any())
|
if (duplicate.Any())
|
||||||
{
|
{
|
||||||
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
ModelState.AddModelError(nameof(dto.TierName), "A tier with this name already exists.");
|
||||||
@@ -138,7 +140,8 @@ public class PricingTiersController : Controller
|
|||||||
if (entity == null) return NotFound();
|
if (entity == null) return NotFound();
|
||||||
|
|
||||||
// Block delete if customers are assigned to this tier
|
// Block delete if customers are assigned to this tier
|
||||||
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.PricingTierId == id);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var assignedCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId && c.PricingTierId == id);
|
||||||
if (assignedCustomers.Any())
|
if (assignedCustomers.Any())
|
||||||
{
|
{
|
||||||
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
|
TempData["ErrorMessage"] = $"Cannot delete '{entity.TierName}' — {assignedCustomers.Count()} customer(s) are assigned to it. Reassign them first.";
|
||||||
|
|||||||
@@ -302,6 +302,9 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
var quoteDto = _mapper.Map<QuoteDto>(quote);
|
var quoteDto = _mapper.Map<QuoteDto>(quote);
|
||||||
|
|
||||||
|
// Bank/asset accounts the deposit can land in (deposit modal dropdown)
|
||||||
|
ViewBag.DepositAccounts = await AccountingDropdownHelper.LoadDepositAccountsAsync(_unitOfWork, companyId);
|
||||||
|
|
||||||
// Get customer info if exists
|
// Get customer info if exists
|
||||||
if (quote.CustomerId.HasValue)
|
if (quote.CustomerId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -2545,7 +2548,8 @@ public class QuotesController : Controller
|
|||||||
efficiency = i.TransferEfficiency ?? 65m,
|
efficiency = i.TransferEfficiency ?? 65m,
|
||||||
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
unitOfMeasure = i.UnitOfMeasure ?? "lbs",
|
||||||
categoryName = i.InventoryCategory!.DisplayName,
|
categoryName = i.InventoryCategory!.DisplayName,
|
||||||
costPerLb = i.UnitCost,
|
// Quote at the current catalog price when linked; fall back to their cost otherwise.
|
||||||
|
costPerLb = i.CatalogReferencePrice ?? i.UnitCost,
|
||||||
colorName = i.ColorName ?? i.Name,
|
colorName = i.ColorName ?? i.Name,
|
||||||
colorCode = i.ColorCode ?? "",
|
colorCode = i.ColorCode ?? "",
|
||||||
isIncoming = i.IsIncoming
|
isIncoming = i.IsIncoming
|
||||||
@@ -3424,7 +3428,7 @@ public class QuotesController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||||
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||||
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
|
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -3518,7 +3522,7 @@ public class QuotesController : Controller
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
var powders = await _unitOfWork.InventoryItems.FindAsync(i =>
|
||||||
i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
i.CompanyId == companyId && i.Category != null && i.Category.ToLower().Contains("powder") && i.UnitCost > 0);
|
||||||
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
|
avgPowderCost = powders.Any() ? powders.Average(p => p.UnitCost) : 8m;
|
||||||
}
|
}
|
||||||
catch { avgPowderCost = 8m; }
|
catch { avgPowderCost = 8m; }
|
||||||
@@ -3613,7 +3617,7 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
// Pull recent accepted predictions (user didn't override) as few-shot calibration examples
|
// Pull recent accepted predictions (user didn't override) as few-shot calibration examples
|
||||||
var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync(
|
var allPredictions = await _unitOfWork.AiItemPredictions.FindAsync(
|
||||||
p => !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
p => p.CompanyId == companyId && !p.UserOverrodeEstimate && p.PredictedSurfaceAreaSqFt > 0 && p.PredictedUnitPrice > 0);
|
||||||
|
|
||||||
context.AcceptedExamples = allPredictions
|
context.AcceptedExamples = allPredictions
|
||||||
.OrderByDescending(p => p.CreatedAt)
|
.OrderByDescending(p => p.CreatedAt)
|
||||||
@@ -3656,9 +3660,11 @@ public class QuotesController : Controller
|
|||||||
{
|
{
|
||||||
var sqFtMin = sqFt * 0.4m;
|
var sqFtMin = sqFt * 0.4m;
|
||||||
var sqFtMax = sqFt * 2.5m;
|
var sqFtMax = sqFt * 2.5m;
|
||||||
|
var companyId = (await _userManager.GetUserAsync(User))?.CompanyId ?? 0;
|
||||||
|
|
||||||
var matches = await _unitOfWork.JobItems.FindAsync(
|
var matches = await _unitOfWork.JobItems.FindAsync(
|
||||||
ji => ji.Complexity == complexity
|
ji => ji.CompanyId == companyId
|
||||||
|
&& ji.Complexity == complexity
|
||||||
&& ji.SurfaceAreaSqFt >= sqFtMin
|
&& ji.SurfaceAreaSqFt >= sqFtMin
|
||||||
&& ji.SurfaceAreaSqFt <= sqFtMax
|
&& ji.SurfaceAreaSqFt <= sqFtMax
|
||||||
&& ji.UnitPrice > 0
|
&& ji.UnitPrice > 0
|
||||||
@@ -3666,7 +3672,7 @@ public class QuotesController : Controller
|
|||||||
|
|
||||||
var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList();
|
var jobIds = matches.Select(ji => ji.JobId).Distinct().ToList();
|
||||||
var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync(
|
var completedStatusIds = (await _unitOfWork.JobStatusLookups.FindAsync(
|
||||||
s => s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered))
|
s => s.CompanyId == companyId && (s.StatusCode == AppConstants.StatusCodes.Job.Completed || s.StatusCode == AppConstants.StatusCodes.Job.Delivered)))
|
||||||
.Select(s => s.Id).ToHashSet();
|
.Select(s => s.Id).ToHashSet();
|
||||||
var completedJobs = await _unitOfWork.Jobs.FindAsync(
|
var completedJobs = await _unitOfWork.Jobs.FindAsync(
|
||||||
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
|
j => jobIds.Contains(j.Id) && completedStatusIds.Contains(j.JobStatusId));
|
||||||
|
|||||||
@@ -590,7 +590,8 @@ public class ReportsController : Controller
|
|||||||
|
|
||||||
// === POWDER USAGE ANALYTICS ===
|
// === POWDER USAGE ANALYTICS ===
|
||||||
var powderTransactions = (await _unitOfWork.InventoryTransactions
|
var powderTransactions = (await _unitOfWork.InventoryTransactions
|
||||||
.FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage
|
.FindAsync(t => t.CompanyId == companyId
|
||||||
|
&& t.TransactionType == InventoryTransactionType.JobUsage
|
||||||
&& t.TransactionDate >= startDate,
|
&& t.TransactionDate >= startDate,
|
||||||
false,
|
false,
|
||||||
t => t.InventoryItem))
|
t => t.InventoryItem))
|
||||||
@@ -1250,6 +1251,20 @@ public class ReportsController : Controller
|
|||||||
return View(dto);
|
return View(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Balance reconciliation diagnostic: each account's stored CurrentBalance vs its recomputed ledger
|
||||||
|
/// balance, plus AR/AP subledger totals vs their control accounts. Read-only; surfaces drift in the
|
||||||
|
/// denormalized balances without changing any posting. Gated behind <see cref="AllowAccounting"/>.
|
||||||
|
/// </summary>
|
||||||
|
// GET: /Reports/Reconciliation
|
||||||
|
public async Task<IActionResult> Reconciliation()
|
||||||
|
{
|
||||||
|
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
|
||||||
|
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
|
||||||
|
var dto = await _financialReports.GetBalanceReconciliationAsync(companyId);
|
||||||
|
return View(dto);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
|
/// PDF export of the Trial Balance report. Same inline/attachment pattern as other PDF actions.
|
||||||
/// Gated behind <see cref="AllowAccounting"/>.
|
/// Gated behind <see cref="AllowAccounting"/>.
|
||||||
@@ -2498,7 +2513,7 @@ public class ReportsController : Controller
|
|||||||
var reportYear = year ?? DateTime.Now.Year;
|
var reportYear = year ?? DateTime.Now.Year;
|
||||||
|
|
||||||
// Load all budgets for the year for the selector
|
// Load all budgets for the year for the selector
|
||||||
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.FiscalYear == reportYear))
|
var allBudgets = (await _unitOfWork.Budgets.FindAsync(b => b.CompanyId == companyId && b.FiscalYear == reportYear))
|
||||||
.OrderBy(b => b.Name).ToList();
|
.OrderBy(b => b.Name).ToList();
|
||||||
|
|
||||||
Core.Entities.Budget? budget = null;
|
Core.Entities.Budget? budget = null;
|
||||||
@@ -2506,10 +2521,10 @@ public class ReportsController : Controller
|
|||||||
budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);
|
budget = await _unitOfWork.Budgets.GetByIdAsync(budgetId.Value, false, b => b.Lines);
|
||||||
|
|
||||||
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
||||||
b => b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
|
b => b.CompanyId == companyId && b.FiscalYear == reportYear && b.IsDefault, false, b => b.Lines)).FirstOrDefault();
|
||||||
|
|
||||||
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
budget ??= (await _unitOfWork.Budgets.FindAsync(
|
||||||
b => b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
|
b => b.CompanyId == companyId && b.FiscalYear == reportYear, false, b => b.Lines)).FirstOrDefault();
|
||||||
|
|
||||||
ViewBag.ReportYear = reportYear;
|
ViewBag.ReportYear = reportYear;
|
||||||
ViewBag.Budget = budget;
|
ViewBag.Budget = budget;
|
||||||
@@ -2544,7 +2559,7 @@ public class ReportsController : Controller
|
|||||||
|
|
||||||
// Load account metadata for budget lines
|
// Load account metadata for budget lines
|
||||||
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
|
var accountIds = budget.Lines.Select(l => l.AccountId).Distinct().ToList();
|
||||||
var accounts = (await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id)))
|
var accounts = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == budget.CompanyId && accountIds.Contains(a.Id)))
|
||||||
.ToDictionary(a => a.Id);
|
.ToDictionary(a => a.Id);
|
||||||
|
|
||||||
var rows = new List<BudgetVsActualRow>();
|
var rows = new List<BudgetVsActualRow>();
|
||||||
@@ -2585,7 +2600,7 @@ public class ReportsController : Controller
|
|||||||
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
|
var periodEnd = new DateTime(reportYear, 12, 31, 23, 59, 59, DateTimeKind.Utc);
|
||||||
|
|
||||||
// Load 1099-eligible vendors
|
// Load 1099-eligible vendors
|
||||||
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.Is1099Vendor)).ToList();
|
var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.Is1099Vendor)).ToList();
|
||||||
|
|
||||||
var rows = new List<Vendor1099Row>();
|
var rows = new List<Vendor1099Row>();
|
||||||
|
|
||||||
|
|||||||
@@ -134,7 +134,8 @@ public class TaxRatesController : Controller
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task ClearOtherDefaultsAsync(int exceptId)
|
private async Task ClearOtherDefaultsAsync(int exceptId)
|
||||||
{
|
{
|
||||||
var others = await _unitOfWork.TaxRates.FindAsync(r => r.IsDefault && r.Id != exceptId);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
|
var others = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId && r.IsDefault && r.Id != exceptId);
|
||||||
foreach (var r in others)
|
foreach (var r in others)
|
||||||
r.IsDefault = false;
|
r.IsDefault = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,21 @@ using Microsoft.AspNetCore.Identity;
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using PowderCoating.Application.DTOs.Import;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Core.Entities;
|
using PowderCoating.Core.Entities;
|
||||||
using PowderCoating.Core.Enums;
|
using PowderCoating.Core.Enums;
|
||||||
using PowderCoating.Core.Interfaces;
|
using PowderCoating.Core.Interfaces;
|
||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
|
using PowderCoating.Shared.Constants;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
|
||||||
namespace PowderCoating.Web.Controllers;
|
namespace PowderCoating.Web.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
// Bulk import/export + QuickBooks migration tools — gated to the financial-management
|
||||||
|
// permission so low-privilege roles (ReadOnly/Employee/ShopFloor) can't export or
|
||||||
|
// import company data. (Audit #3, 2026-06-20.)
|
||||||
|
[Authorize(Policy = AppConstants.Policies.CanManageInvoices)]
|
||||||
public class ToolsController : Controller
|
public class ToolsController : Controller
|
||||||
{
|
{
|
||||||
private readonly IUnitOfWork _unitOfWork;
|
private readonly IUnitOfWork _unitOfWork;
|
||||||
@@ -1394,6 +1399,53 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Bulk-imports invoice line items from a native CSV file. Lines are matched to their parent
|
||||||
|
/// invoice by InvoiceNumber and revenue accounts resolved by number. Run after the invoice import.
|
||||||
|
/// </summary>
|
||||||
|
// POST: Tools/CsvImportInvoiceItems
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public async Task<IActionResult> CsvImportInvoiceItems(IFormFile file)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null)
|
||||||
|
return Json(new { success = false, message = "Your account is not associated with a company." });
|
||||||
|
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return Json(new { success = false, message = "No file provided or file is empty." });
|
||||||
|
|
||||||
|
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Json(new { success = false, message = "Only CSV files are allowed." });
|
||||||
|
|
||||||
|
_logger.LogInformation("User {UserName} importing invoice items from CSV {FileName} for company {CompanyId}",
|
||||||
|
User.Identity?.Name, file.FileName, companyId);
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var result = await _csvImportService.ImportInvoiceItemsAsync(stream, companyId.Value);
|
||||||
|
await LogCsvImportAsync("InvoiceItems", file.FileName, result);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
message = result.Summary,
|
||||||
|
successCount = result.SuccessCount,
|
||||||
|
skippedCount = result.SkippedCount,
|
||||||
|
errorCount = result.ErrorCount,
|
||||||
|
totalRows = result.TotalRows,
|
||||||
|
errors = result.Errors,
|
||||||
|
warnings = result.Warnings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error importing invoice items from CSV");
|
||||||
|
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Downloads a blank CSV template for the native payment bulk import.
|
/// Downloads a blank CSV template for the native payment bulk import.
|
||||||
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
|
/// Columns match the native ExportPaymentsCsv output for round-trip compatibility.
|
||||||
@@ -1406,6 +1458,90 @@ public class ToolsController : Controller
|
|||||||
return File(csvBytes, "text/csv", "payment_import_template.csv");
|
return File(csvBytes, "text/csv", "payment_import_template.csv");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Downloads a blank CSV template for the invoice line-item bulk import.</summary>
|
||||||
|
// GET: Tools/DownloadInvoiceItemTemplate
|
||||||
|
[HttpGet]
|
||||||
|
public IActionResult DownloadInvoiceItemTemplate()
|
||||||
|
{
|
||||||
|
var csvBytes = _csvImportService.GenerateInvoiceItemTemplate();
|
||||||
|
return File(csvBytes, "text/csv", "invoice_item_import_template.csv");
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: Tools/CsvImportBills — vendor bill headers (vendor by name, AP account by number).
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public Task<IActionResult> CsvImportBills(IFormFile file)
|
||||||
|
=> RunCsvImport(file, "Bills", _csvImportService.ImportBillsAsync);
|
||||||
|
|
||||||
|
// POST: Tools/CsvImportBillLineItems — bill lines (run after bills).
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public Task<IActionResult> CsvImportBillLineItems(IFormFile file)
|
||||||
|
=> RunCsvImport(file, "BillLineItems", _csvImportService.ImportBillLineItemsAsync);
|
||||||
|
|
||||||
|
// POST: Tools/CsvImportDeposits — customer deposits (customer by name, bank account by number).
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public Task<IActionResult> CsvImportDeposits(IFormFile file)
|
||||||
|
=> RunCsvImport(file, "Deposits", _csvImportService.ImportDepositsAsync);
|
||||||
|
|
||||||
|
// POST: Tools/CsvImportJournalEntries — journal entry headers.
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public Task<IActionResult> CsvImportJournalEntries(IFormFile file)
|
||||||
|
=> RunCsvImport(file, "JournalEntries", _csvImportService.ImportJournalEntriesAsync);
|
||||||
|
|
||||||
|
// POST: Tools/CsvImportJournalEntryLines — journal entry lines (run after journal entries).
|
||||||
|
[HttpPost]
|
||||||
|
[ValidateAntiForgeryToken]
|
||||||
|
public Task<IActionResult> CsvImportJournalEntryLines(IFormFile file)
|
||||||
|
=> RunCsvImport(file, "JournalEntryLines", _csvImportService.ImportJournalEntryLinesAsync);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared plumbing for the accounting CSV imports: validates the upload, resolves the company,
|
||||||
|
/// runs the given import function, logs it, and returns the standard JSON result shape.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<IActionResult> RunCsvImport(IFormFile file, string label,
|
||||||
|
Func<Stream, int, Task<CsvImportResultDto>> import)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null)
|
||||||
|
return Json(new { success = false, message = "Your account is not associated with a company." });
|
||||||
|
|
||||||
|
if (file == null || file.Length == 0)
|
||||||
|
return Json(new { success = false, message = "No file provided or file is empty." });
|
||||||
|
|
||||||
|
if (!file.FileName.EndsWith(".csv", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return Json(new { success = false, message = "Only CSV files are allowed." });
|
||||||
|
|
||||||
|
_logger.LogInformation("User {UserName} importing {Label} from CSV {FileName} for company {CompanyId}",
|
||||||
|
User.Identity?.Name, label, file.FileName, companyId);
|
||||||
|
|
||||||
|
using var stream = file.OpenReadStream();
|
||||||
|
var result = await import(stream, companyId.Value);
|
||||||
|
await LogCsvImportAsync(label, file.FileName, result);
|
||||||
|
|
||||||
|
return Json(new
|
||||||
|
{
|
||||||
|
success = result.Success,
|
||||||
|
message = result.Summary,
|
||||||
|
successCount = result.SuccessCount,
|
||||||
|
skippedCount = result.SkippedCount,
|
||||||
|
errorCount = result.ErrorCount,
|
||||||
|
totalRows = result.TotalRows,
|
||||||
|
errors = result.Errors,
|
||||||
|
warnings = result.Warnings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error importing {Label} from CSV", label);
|
||||||
|
return Json(new { success = false, message = $"An error occurred during import: {ex.Message}" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber.
|
/// Bulk-imports payment records from a native CSV file. Invoices are resolved by InvoiceNumber.
|
||||||
/// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
|
/// Duplicate payments (same invoice + date + amount) are skipped. Updates the invoice AmountPaid
|
||||||
@@ -2044,7 +2180,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 11. Invoices
|
// 11. Invoices
|
||||||
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job);
|
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job, i => i.InvoiceItems);
|
||||||
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
var invoicesCsv = GenerateInvoicesCsv(invoices);
|
||||||
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
|
||||||
using (var entryStream = invoicesEntry.Open())
|
using (var entryStream = invoicesEntry.Open())
|
||||||
@@ -2063,6 +2199,17 @@ public class ToolsController : Controller
|
|||||||
await writer.WriteAsync(accountsCsv);
|
await writer.WriteAsync(accountsCsv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 12b. Invoice line items — one row per line, carrying the revenue account number so
|
||||||
|
// the invoice's revenue attribution survives an export/import round-trip.
|
||||||
|
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||||
|
var invoiceItemsCsv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
|
||||||
|
var invoiceItemsEntry = archive.CreateEntry($"invoice_items_{timestamp}.csv");
|
||||||
|
using (var entryStream = invoiceItemsEntry.Open())
|
||||||
|
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(invoiceItemsCsv);
|
||||||
|
}
|
||||||
|
|
||||||
// 13. Expenses
|
// 13. Expenses
|
||||||
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job);
|
||||||
var expensesCsv = GenerateExpensesCsv(expenses);
|
var expensesCsv = GenerateExpensesCsv(expenses);
|
||||||
@@ -2074,7 +2221,7 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 14. Payments
|
// 14. Payments
|
||||||
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice);
|
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice, p => p.DepositAccount);
|
||||||
var paymentsCsv = GeneratePaymentsCsv(payments);
|
var paymentsCsv = GeneratePaymentsCsv(payments);
|
||||||
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
|
||||||
using (var entryStream = paymentsEntry.Open())
|
using (var entryStream = paymentsEntry.Open())
|
||||||
@@ -2083,8 +2230,48 @@ public class ToolsController : Controller
|
|||||||
await writer.WriteAsync(paymentsCsv);
|
await writer.WriteAsync(paymentsCsv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 15. Bills + bill line items (account/job by number, AP account, vendor by name)
|
||||||
|
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
|
||||||
|
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount, b => b.LineItems);
|
||||||
|
var billsEntry = archive.CreateEntry($"bills_{timestamp}.csv");
|
||||||
|
using (var entryStream = billsEntry.Open())
|
||||||
|
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(GenerateBillsCsv(bills));
|
||||||
|
}
|
||||||
|
var billLineItemsEntry = archive.CreateEntry($"bill_line_items_{timestamp}.csv");
|
||||||
|
using (var entryStream = billLineItemsEntry.Open())
|
||||||
|
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16. Deposits (customer by name, bank account + applied invoice by number)
|
||||||
|
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
|
||||||
|
var depositsEntry = archive.CreateEntry($"deposits_{timestamp}.csv");
|
||||||
|
using (var entryStream = depositsEntry.Open())
|
||||||
|
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(GenerateDepositsCsv(deposits, accountNumberById));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 17. Journal entries + lines (account by number, debit/credit)
|
||||||
|
var journalEntries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
|
||||||
|
var journalEntriesEntry = archive.CreateEntry($"journal_entries_{timestamp}.csv");
|
||||||
|
using (var entryStream = journalEntriesEntry.Open())
|
||||||
|
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(GenerateJournalEntriesCsv(journalEntries));
|
||||||
|
}
|
||||||
|
var journalEntryLinesEntry = archive.CreateEntry($"journal_entry_lines_{timestamp}.csv");
|
||||||
|
using (var entryStream = journalEntryLinesEntry.Open())
|
||||||
|
using (var writer = new System.IO.StreamWriter(entryStream))
|
||||||
|
{
|
||||||
|
await writer.WriteAsync(GenerateJournalEntryLinesCsv(journalEntries, accountNumberById));
|
||||||
|
}
|
||||||
|
|
||||||
// 15. Purchase Orders
|
// 15. Purchase Orders
|
||||||
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
|
var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
|
||||||
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
|
var purchaseOrdersCsv = GeneratePurchaseOrdersCsv(purchaseOrders);
|
||||||
var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv");
|
var purchaseOrdersEntry = archive.CreateEntry($"purchase_orders_{timestamp}.csv");
|
||||||
using (var entryStream = purchaseOrdersEntry.Open())
|
using (var entryStream = purchaseOrdersEntry.Open())
|
||||||
@@ -2147,7 +2334,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var customers = await _unitOfWork.Customers.GetAllAsync(false, c => c.PricingTier);
|
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId, false, c => c.PricingTier);
|
||||||
var csv = GenerateCustomersCsv(customers);
|
var csv = GenerateCustomersCsv(customers);
|
||||||
var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"customers_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2181,7 +2368,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus);
|
var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus);
|
||||||
var csv = GenerateQuotesCsv(quotes);
|
var csv = GenerateQuotesCsv(quotes);
|
||||||
var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"quotes_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2214,7 +2401,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority);
|
||||||
var csv = GenerateJobsCsv(jobs);
|
var csv = GenerateJobsCsv(jobs);
|
||||||
var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"jobs_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2246,7 +2433,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var appointments = await _unitOfWork.Appointments.GetAllAsync(false,
|
var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId, false,
|
||||||
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
|
a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
|
||||||
var csv = GenerateAppointmentsCsv(appointments);
|
var csv = GenerateAppointmentsCsv(appointments);
|
||||||
var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"appointments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
@@ -2316,7 +2503,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var inventoryItems = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.PrimaryVendor);
|
var inventoryItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.PrimaryVendor);
|
||||||
var csv = GenerateInventoryCsv(inventoryItems);
|
var csv = GenerateInventoryCsv(inventoryItems);
|
||||||
var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"inventory_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2383,7 +2570,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment);
|
var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId, false, m => m.Equipment);
|
||||||
var csv = GenerateMaintenanceCsv(maintenance);
|
var csv = GenerateMaintenanceCsv(maintenance);
|
||||||
var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"maintenance_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2471,7 +2658,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job);
|
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Job);
|
||||||
var csv = GenerateInvoicesCsv(invoices);
|
var csv = GenerateInvoicesCsv(invoices);
|
||||||
var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"invoices_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2504,7 +2691,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice);
|
var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId, false, p => p.Invoice, p => p.DepositAccount);
|
||||||
var csv = GeneratePaymentsCsv(payments);
|
var csv = GeneratePaymentsCsv(payments);
|
||||||
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"payments_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -2519,6 +2706,164 @@ public class ToolsController : Controller
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exports all invoice line items for the current company as a CSV, keyed by parent invoice number
|
||||||
|
/// and carrying each line's revenue account number. Complements <see cref="ExportInvoicesCsv"/>
|
||||||
|
/// (which is header-only) so invoice detail and revenue attribution round-trip on re-import.
|
||||||
|
/// </summary>
|
||||||
|
// GET: Tools/ExportInvoiceItemsCsv
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportInvoiceItemsCsv()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null)
|
||||||
|
{
|
||||||
|
TempData["ErrorMessage"] = "Your account is not associated with a company.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
|
||||||
|
var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.InvoiceItems);
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
|
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||||
|
var csv = GenerateInvoiceItemsCsv(invoices, accountNumberById);
|
||||||
|
var fileName = $"invoice_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
|
await LogExportAsync("InvoiceItems", $"CSV export ({invoices.Sum(i => i.InvoiceItems.Count)} line items)");
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", fileName);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error exporting invoice items to CSV");
|
||||||
|
TempData["ErrorMessage"] = "An error occurred while exporting invoice items.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exports vendor bill headers (vendor by name, AP account by number) as CSV.</summary>
|
||||||
|
// GET: Tools/ExportBillsCsv
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportBillsCsv()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||||
|
|
||||||
|
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.Vendor, b => b.APAccount);
|
||||||
|
var csv = GenerateBillsCsv(bills);
|
||||||
|
await LogExportAsync("Bills", $"CSV export ({bills.Count()} records)");
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bills_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error exporting bills to CSV");
|
||||||
|
TempData["ErrorMessage"] = "An error occurred while exporting bills.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exports vendor bill line items (account/job by number) as CSV.</summary>
|
||||||
|
// GET: Tools/ExportBillLineItemsCsv
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportBillLineItemsCsv()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||||
|
|
||||||
|
var bills = await _unitOfWork.Bills.FindAsync(b => b.CompanyId == companyId.Value, false, b => b.LineItems);
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
|
var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value);
|
||||||
|
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||||
|
var jobNumberById = jobs.Where(j => !string.IsNullOrEmpty(j.JobNumber)).ToDictionary(j => j.Id, j => j.JobNumber);
|
||||||
|
var csv = GenerateBillLineItemsCsv(bills, accountNumberById, jobNumberById);
|
||||||
|
await LogExportAsync("BillLineItems", $"CSV export ({bills.Sum(b => b.LineItems.Count)} line items)");
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"bill_line_items_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error exporting bill line items to CSV");
|
||||||
|
TempData["ErrorMessage"] = "An error occurred while exporting bill line items.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exports customer deposits (customer by name, bank account + applied invoice by number) as CSV.</summary>
|
||||||
|
// GET: Tools/ExportDepositsCsv
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportDepositsCsv()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||||
|
|
||||||
|
var deposits = await _unitOfWork.Deposits.FindAsync(d => d.CompanyId == companyId.Value, false, d => d.Customer, d => d.AppliedToInvoice);
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
|
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||||
|
var csv = GenerateDepositsCsv(deposits, accountNumberById);
|
||||||
|
await LogExportAsync("Deposits", $"CSV export ({deposits.Count()} records)");
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"deposits_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error exporting deposits to CSV");
|
||||||
|
TempData["ErrorMessage"] = "An error occurred while exporting deposits.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exports journal entry headers as CSV. Lines export separately.</summary>
|
||||||
|
// GET: Tools/ExportJournalEntriesCsv
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportJournalEntriesCsv()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||||
|
|
||||||
|
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value);
|
||||||
|
var csv = GenerateJournalEntriesCsv(entries);
|
||||||
|
await LogExportAsync("JournalEntries", $"CSV export ({entries.Count()} records)");
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entries_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error exporting journal entries to CSV");
|
||||||
|
TempData["ErrorMessage"] = "An error occurred while exporting journal entries.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Exports journal entry lines (account by number, debit/credit) as CSV.</summary>
|
||||||
|
// GET: Tools/ExportJournalEntryLinesCsv
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> ExportJournalEntryLinesCsv()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId();
|
||||||
|
if (companyId == null) { TempData["ErrorMessage"] = "Your account is not associated with a company."; return RedirectToAction(nameof(Index)); }
|
||||||
|
|
||||||
|
var entries = await _unitOfWork.JournalEntries.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Lines);
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value);
|
||||||
|
var accountNumberById = accounts.ToDictionary(a => a.Id, a => a.AccountNumber ?? "");
|
||||||
|
var csv = GenerateJournalEntryLinesCsv(entries, accountNumberById);
|
||||||
|
await LogExportAsync("JournalEntryLines", $"CSV export ({entries.Sum(e => e.Lines.Count)} lines)");
|
||||||
|
return File(System.Text.Encoding.UTF8.GetBytes(csv), "text/csv", $"journal_entry_lines_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error exporting journal entry lines to CSV");
|
||||||
|
TempData["ErrorMessage"] = "An error occurred while exporting journal entry lines.";
|
||||||
|
return RedirectToAction(nameof(Index));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exports all purchase orders for the current company as a CSV file, including the vendor
|
/// Exports all purchase orders for the current company as a CSV file, including the vendor
|
||||||
/// company name resolved via eager loading. PO status is written as its enum name.
|
/// company name resolved via eager loading. PO status is written as its enum name.
|
||||||
@@ -2536,7 +2881,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var purchaseOrders = await _unitOfWork.PurchaseOrders.GetAllAsync(false, po => po.Vendor);
|
var purchaseOrders = await _unitOfWork.PurchaseOrders.FindAsync(po => po.CompanyId == companyId, false, po => po.Vendor);
|
||||||
var csv = GeneratePurchaseOrdersCsv(purchaseOrders);
|
var csv = GeneratePurchaseOrdersCsv(purchaseOrders);
|
||||||
var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
var fileName = $"purchase_orders_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
|
||||||
|
|
||||||
@@ -3973,6 +4318,35 @@ public class ToolsController : Controller
|
|||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds a CSV of invoice line items — one row per item across all the given invoices. The parent
|
||||||
|
/// invoice number and the line's revenue account number (resolved from <paramref name="accountNumberById"/>)
|
||||||
|
/// are written so revenue attribution survives an export/import round-trip. Rows are emitted in
|
||||||
|
/// DisplayOrder within each invoice.
|
||||||
|
/// </summary>
|
||||||
|
private string GenerateInvoiceItemsCsv(IEnumerable<Core.Entities.Invoice> invoices, IReadOnlyDictionary<int, string> accountNumberById)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("InvoiceNumber,Description,Quantity,UnitPrice,TotalPrice,ColorName,RevenueAccountNumber,DisplayOrder,Notes");
|
||||||
|
|
||||||
|
foreach (var invoice in invoices)
|
||||||
|
{
|
||||||
|
foreach (var item in invoice.InvoiceItems.OrderBy(it => it.DisplayOrder))
|
||||||
|
{
|
||||||
|
var revenueAccountNumber = item.RevenueAccountId.HasValue
|
||||||
|
&& accountNumberById.TryGetValue(item.RevenueAccountId.Value, out var num)
|
||||||
|
? num : "";
|
||||||
|
|
||||||
|
sb.AppendLine($"{EscapeCsv(invoice.InvoiceNumber)},{EscapeCsv(item.Description)}," +
|
||||||
|
$"{item.Quantity},{item.UnitPrice},{item.TotalPrice}," +
|
||||||
|
$"{EscapeCsv(item.ColorName)},{EscapeCsv(revenueAccountNumber)}," +
|
||||||
|
$"{item.DisplayOrder},{EscapeCsv(item.Notes)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a CSV string for the given invoice payment records. The parent invoice number is
|
/// Builds a CSV string for the given invoice payment records. The parent invoice number is
|
||||||
/// resolved from the eagerly loaded <c>Invoice</c> navigation property. PaymentMethod is
|
/// resolved from the eagerly loaded <c>Invoice</c> navigation property. PaymentMethod is
|
||||||
@@ -3981,13 +4355,111 @@ public class ToolsController : Controller
|
|||||||
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
|
private string GeneratePaymentsCsv(IEnumerable<Core.Entities.Payment> payments)
|
||||||
{
|
{
|
||||||
var sb = new System.Text.StringBuilder();
|
var sb = new System.Text.StringBuilder();
|
||||||
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,Reference,Notes");
|
sb.AppendLine("InvoiceNumber,Amount,PaymentDate,PaymentMethod,DepositAccountNumber,Reference,Notes");
|
||||||
|
|
||||||
foreach (var payment in payments)
|
foreach (var payment in payments)
|
||||||
{
|
{
|
||||||
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
|
sb.AppendLine($"{EscapeCsv(payment.Invoice?.InvoiceNumber)}," +
|
||||||
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
|
$"{payment.Amount},{payment.PaymentDate:yyyy-MM-dd}," +
|
||||||
$"{payment.PaymentMethod},{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
|
$"{payment.PaymentMethod},{EscapeCsv(payment.DepositAccount?.AccountNumber)}," +
|
||||||
|
$"{EscapeCsv(payment.Reference)},{EscapeCsv(payment.Notes)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the vendor bill header CSV — vendor by name, AP account by number.</summary>
|
||||||
|
private string GenerateBillsCsv(IEnumerable<Core.Entities.Bill> bills)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("BillNumber,VendorInvoiceNumber,VendorName,APAccountNumber,BillDate,DueDate,Status,Terms,Memo,SubTotal,TaxPercent,TaxAmount,Total,AmountPaid");
|
||||||
|
|
||||||
|
foreach (var bill in bills)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(bill.VendorInvoiceNumber)}," +
|
||||||
|
$"{EscapeCsv(bill.Vendor?.CompanyName)},{EscapeCsv(bill.APAccount?.AccountNumber)}," +
|
||||||
|
$"{bill.BillDate:yyyy-MM-dd},{bill.DueDate?.ToString("yyyy-MM-dd")},{bill.Status}," +
|
||||||
|
$"{EscapeCsv(bill.Terms)},{EscapeCsv(bill.Memo)}," +
|
||||||
|
$"{bill.SubTotal},{bill.TaxPercent},{bill.TaxAmount},{bill.Total},{bill.AmountPaid}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the bill line-item CSV — one row per line, account/job resolved by number.</summary>
|
||||||
|
private string GenerateBillLineItemsCsv(IEnumerable<Core.Entities.Bill> bills,
|
||||||
|
IReadOnlyDictionary<int, string> accountNumberById, IReadOnlyDictionary<int, string> jobNumberById)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("BillNumber,AccountNumber,JobNumber,Description,Quantity,UnitPrice,Amount,DisplayOrder");
|
||||||
|
|
||||||
|
foreach (var bill in bills)
|
||||||
|
{
|
||||||
|
foreach (var line in bill.LineItems.OrderBy(li => li.DisplayOrder))
|
||||||
|
{
|
||||||
|
var accountNumber = line.AccountId.HasValue && accountNumberById.TryGetValue(line.AccountId.Value, out var an) ? an : "";
|
||||||
|
var jobNumber = line.JobId.HasValue && jobNumberById.TryGetValue(line.JobId.Value, out var jn) ? jn : "";
|
||||||
|
sb.AppendLine($"{EscapeCsv(bill.BillNumber)},{EscapeCsv(accountNumber)},{EscapeCsv(jobNumber)}," +
|
||||||
|
$"{EscapeCsv(line.Description)},{line.Quantity},{line.UnitPrice},{line.Amount},{line.DisplayOrder}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the customer deposit CSV — customer by name, bank account + applied invoice resolved.</summary>
|
||||||
|
private string GenerateDepositsCsv(IEnumerable<Core.Entities.Deposit> deposits, IReadOnlyDictionary<int, string> accountNumberById)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("ReceiptNumber,CustomerName,Amount,PaymentMethod,ReceivedDate,DepositAccountNumber,AppliedToInvoiceNumber,AppliedDate,Reference,Notes");
|
||||||
|
|
||||||
|
foreach (var deposit in deposits)
|
||||||
|
{
|
||||||
|
var customerName = deposit.Customer != null
|
||||||
|
? (!string.IsNullOrWhiteSpace(deposit.Customer.CompanyName)
|
||||||
|
? deposit.Customer.CompanyName
|
||||||
|
: $"{deposit.Customer.ContactFirstName} {deposit.Customer.ContactLastName}".Trim())
|
||||||
|
: "";
|
||||||
|
var depositAccountNumber = deposit.DepositAccountId.HasValue && accountNumberById.TryGetValue(deposit.DepositAccountId.Value, out var an) ? an : "";
|
||||||
|
|
||||||
|
sb.AppendLine($"{EscapeCsv(deposit.ReceiptNumber)},{EscapeCsv(customerName)},{deposit.Amount}," +
|
||||||
|
$"{deposit.PaymentMethod},{deposit.ReceivedDate:yyyy-MM-dd},{EscapeCsv(depositAccountNumber)}," +
|
||||||
|
$"{EscapeCsv(deposit.AppliedToInvoice?.InvoiceNumber)},{deposit.AppliedDate?.ToString("yyyy-MM-dd")}," +
|
||||||
|
$"{EscapeCsv(deposit.Reference)},{EscapeCsv(deposit.Notes)}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the journal entry header CSV. Lines are exported separately.</summary>
|
||||||
|
private string GenerateJournalEntriesCsv(IEnumerable<Core.Entities.JournalEntry> entries)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("EntryNumber,EntryDate,Reference,Description,Status");
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{entry.EntryDate:yyyy-MM-dd}," +
|
||||||
|
$"{EscapeCsv(entry.Reference)},{EscapeCsv(entry.Description)},{entry.Status}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds the journal entry line CSV — one row per debit/credit line, account by number.</summary>
|
||||||
|
private string GenerateJournalEntryLinesCsv(IEnumerable<Core.Entities.JournalEntry> entries, IReadOnlyDictionary<int, string> accountNumberById)
|
||||||
|
{
|
||||||
|
var sb = new System.Text.StringBuilder();
|
||||||
|
sb.AppendLine("EntryNumber,AccountNumber,DebitAmount,CreditAmount,Description,LineOrder");
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
foreach (var line in entry.Lines.OrderBy(l => l.LineOrder))
|
||||||
|
{
|
||||||
|
var accountNumber = accountNumberById.TryGetValue(line.AccountId, out var an) ? an : "";
|
||||||
|
sb.AppendLine($"{EscapeCsv(entry.EntryNumber)},{EscapeCsv(accountNumber)}," +
|
||||||
|
$"{line.DebitAmount},{line.CreditAmount},{EscapeCsv(line.Description)},{line.LineOrder}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sb.ToString();
|
return sb.ToString();
|
||||||
@@ -4143,7 +4615,7 @@ public class ToolsController : Controller
|
|||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
var expenses = await _unitOfWork.Expenses.GetAllAsync(false,
|
var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId, false,
|
||||||
e => e.ExpenseAccount,
|
e => e.ExpenseAccount,
|
||||||
e => e.PaymentAccount,
|
e => e.PaymentAccount,
|
||||||
e => e.Vendor,
|
e => e.Vendor,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ public class VendorCreditsController : Controller
|
|||||||
.Select(l => l.AccountId!.Value)
|
.Select(l => l.AccountId!.Value)
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToList();
|
.ToList();
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => accountIds.Contains(a.Id));
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == vc.CompanyId && accountIds.Contains(a.Id));
|
||||||
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
ViewBag.AccountMap = accounts.ToDictionary(a => a.Id, a => $"{a.AccountNumber} – {a.Name}");
|
||||||
|
|
||||||
// Load bills referenced by applications
|
// Load bills referenced by applications
|
||||||
@@ -357,8 +357,9 @@ public class VendorCreditsController : Controller
|
|||||||
|
|
||||||
private async Task PopulateDropdownsAsync()
|
private async Task PopulateDropdownsAsync()
|
||||||
{
|
{
|
||||||
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.IsActive);
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.IsActive);
|
var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId && v.IsActive);
|
||||||
|
var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId && a.IsActive);
|
||||||
|
|
||||||
ViewBag.VendorList = vendors
|
ViewBag.VendorList = vendors
|
||||||
.OrderBy(v => v.CompanyName)
|
.OrderBy(v => v.CompanyName)
|
||||||
|
|||||||
@@ -463,8 +463,9 @@ public class VendorsController : Controller
|
|||||||
|
|
||||||
private async Task PopulateExpenseAccountsAsync()
|
private async Task PopulateExpenseAccountsAsync()
|
||||||
{
|
{
|
||||||
|
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
|
||||||
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
var accounts = (await _unitOfWork.Accounts.FindAsync(
|
||||||
a => a.IsActive && (a.AccountType == AccountType.Expense ||
|
a => a.CompanyId == companyId && a.IsActive && (a.AccountType == AccountType.Expense ||
|
||||||
a.AccountType == AccountType.CostOfGoods ||
|
a.AccountType == AccountType.CostOfGoods ||
|
||||||
a.AccountType == AccountType.Asset)))
|
a.AccountType == AccountType.Asset)))
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using PowderCoating.Core.Enums;
|
||||||
|
using PowderCoating.Core.Interfaces;
|
||||||
|
|
||||||
|
namespace PowderCoating.Web.Helpers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Server-side validation for account selections that must be a "money" account — a payment
|
||||||
|
/// source (bill payment), deposit target, or reconcilable account. The dropdowns already limit
|
||||||
|
/// the choices, so this is defense in depth against tampered or stale POSTs (e.g. an account
|
||||||
|
/// deleted/retyped between page load and submit): it rejects anything that isn't an active,
|
||||||
|
/// company-owned Asset or Liability account before a GL posting is made against it.
|
||||||
|
/// </summary>
|
||||||
|
internal static class AccountGuard
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when <paramref name="accountId"/> identifies an active account belonging to
|
||||||
|
/// <paramref name="companyId"/> whose top-level type is Asset or Liability. Filters CompanyId
|
||||||
|
/// explicitly (defense in depth alongside the global tenant filter).
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<bool> IsValidMoneyAccountAsync(IUnitOfWork unitOfWork, int? accountId, int companyId)
|
||||||
|
{
|
||||||
|
if (accountId == null) return false;
|
||||||
|
var account = await unitOfWork.Accounts.FirstOrDefaultAsync(
|
||||||
|
a => a.Id == accountId.Value && a.CompanyId == companyId && a.IsActive);
|
||||||
|
return account != null
|
||||||
|
&& (account.AccountType == AccountType.Asset || account.AccountType == AccountType.Liability);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,27 @@ internal static class AccountingDropdownHelper
|
|||||||
/// Returns pre-projected SelectListItem collections so controllers avoid duplicating the
|
/// Returns pre-projected SelectListItem collections so controllers avoid duplicating the
|
||||||
/// LINQ-to-SelectListItem transform.
|
/// LINQ-to-SelectListItem transform.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <summary>
|
||||||
|
/// Loads the accounts a customer deposit can land in — any active Asset or Liability
|
||||||
|
/// account for the company (filtered by parent AccountType, not sub-type, so accounts a
|
||||||
|
/// company classified differently still appear). Checking/Cash accounts sort to the top
|
||||||
|
/// as the usual choice. Used to populate the deposit modal's account dropdown on the Job
|
||||||
|
/// and Quote details pages. CompanyId is filtered explicitly (defense in depth).
|
||||||
|
/// </summary>
|
||||||
|
internal static async Task<List<SelectListItem>> LoadDepositAccountsAsync(IUnitOfWork unitOfWork, int companyId)
|
||||||
|
{
|
||||||
|
var accounts = await unitOfWork.Accounts.FindAsync(
|
||||||
|
a => a.CompanyId == companyId && a.IsActive
|
||||||
|
&& (a.AccountType == AccountType.Asset || a.AccountType == AccountType.Liability));
|
||||||
|
|
||||||
|
return accounts
|
||||||
|
.OrderByDescending(a => a.AccountSubType == AccountSubType.Checking ||
|
||||||
|
a.AccountSubType == AccountSubType.Cash)
|
||||||
|
.ThenBy(a => a.AccountNumber)
|
||||||
|
.Select(a => new SelectListItem($"{a.AccountNumber} – {a.Name}", a.Id.ToString()))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
internal static async Task<AccountingDropdowns> LoadAsync(IUnitOfWork unitOfWork)
|
internal static async Task<AccountingDropdowns> LoadAsync(IUnitOfWork unitOfWork)
|
||||||
{
|
{
|
||||||
var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive);
|
var vendors = await unitOfWork.Vendors.FindAsync(v => v.IsActive);
|
||||||
@@ -50,17 +71,21 @@ internal static class AccountingDropdownHelper
|
|||||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
|
||||||
|
// Filter by parent AccountType only — not sub-type. Companies classify their
|
||||||
|
// own accounts differently (e.g. a "Line of Credit" they treat as a payable),
|
||||||
|
// so listing every account of the right top-level type lets them pick what they
|
||||||
|
// actually use instead of silently hiding accounts on a sub-type mismatch.
|
||||||
ApAccounts = allAccounts
|
ApAccounts = allAccounts
|
||||||
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
|
.Where(a => a.AccountType == AccountType.Liability)
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
|
||||||
|
// Payment sources span both Assets (cash/checking/savings) and Liabilities
|
||||||
|
// (credit cards, lines of credit), so include both top-level types.
|
||||||
BankAccounts = allAccounts
|
BankAccounts = allAccounts
|
||||||
.Where(a => a.AccountSubType == AccountSubType.Cash ||
|
.Where(a => a.AccountType == AccountType.Asset ||
|
||||||
a.AccountSubType == AccountSubType.Checking ||
|
a.AccountType == AccountType.Liability)
|
||||||
a.AccountSubType == AccountSubType.Savings ||
|
|
||||||
a.AccountSubType == AccountSubType.CreditCard)
|
|
||||||
.OrderBy(a => a.AccountNumber)
|
.OrderBy(a => a.AccountNumber)
|
||||||
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
.Select(a => new SelectListItem(accountLabel(a), a.Id.ToString()))
|
||||||
.ToList(),
|
.ToList(),
|
||||||
|
|||||||
@@ -481,6 +481,8 @@ public static class HelpKnowledgeBase
|
|||||||
5. Enter opening quantity on hand — the system automatically records an Initial transaction for audit purposes
|
5. Enter opening quantity on hand — the system automatically records an Initial transaction for audit purposes
|
||||||
6. Save
|
6. Save
|
||||||
|
|
||||||
|
**Manufacturer catalog integration:** The platform is integrated with the Columbia Coatings product catalog. When you add a Columbia powder it auto-fills the color, specs, cure schedule, and SDS/TDS links, and keeps the price current — catalog data refreshes regularly (near real-time). Quotes use the latest catalog price even when your stored cost is older, and a powder's detail page shows the current catalog price (and flags when it has changed since you last bought it). Discontinued powders are flagged "cannot reorder" but stay usable for stock you already have.
|
||||||
|
|
||||||
**Stock status:** Three states are shown on every item:
|
**Stock status:** Three states are shown on every item:
|
||||||
- **In Stock** (green) — quantity is above the reorder point
|
- **In Stock** (green) — quantity is above the reorder point
|
||||||
- **Low Stock** (red) — quantity is greater than zero but at or below the reorder point; time to reorder
|
- **Low Stock** (red) — quantity is greater than zero but at or below the reorder point; time to reorder
|
||||||
@@ -676,6 +678,12 @@ public static class HelpKnowledgeBase
|
|||||||
**Step 5 — Set up your Chart of Accounts (for billing/AP)**
|
**Step 5 — Set up your Chart of Accounts (for billing/AP)**
|
||||||
If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically.
|
If you use the Bills and accounting features, go to [Chart of Accounts](/Accounts) and confirm the seeded accounts fit your setup. The wizard seeds a standard set automatically.
|
||||||
|
|
||||||
|
**Trial balance indicator (Chart of Accounts)**
|
||||||
|
The Chart of Accounts page shows a Trial Balance badge: "Balanced" when total debits equal total credits, otherwise how far off the books are (excess debits/credits). A non-zero value usually means opening balances were entered without an offsetting entry, or a one-sided posting. Run Recalculate Balances first; if it persists, review opening balances.
|
||||||
|
|
||||||
|
**Default accounts (Chart of Accounts → Set Defaults)**
|
||||||
|
On the Chart of Accounts page, the "Default Accounts" card lets you choose a default Revenue, COGS, and Inventory account for your company. These are used automatically when an item or invoice line doesn't specify one: invoice lines fall back to your default Revenue account (then to account 4000 if none is set), and new inventory and catalog items are pre-filled with your default COGS/Inventory accounts. Leave any blank to keep the current behavior. Note: setting BOTH a COGS and an Inventory Asset default makes new items post inventory-consumption COGS (perpetual inventory) — leave them blank if you expense materials when you purchase them.
|
||||||
|
|
||||||
**What happens if Operating Costs are zero?**
|
**What happens if Operating Costs are zero?**
|
||||||
If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix.
|
If you skip the pricing setup steps, every quote will calculate $0 (or only the tax amount). The Dashboard "Setup Incomplete" card will show red badges pointing to exactly what's missing and link directly to the fix.
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces.Services;
|
|||||||
using PowderCoating.Infrastructure.Data;
|
using PowderCoating.Infrastructure.Data;
|
||||||
using PowderCoating.Infrastructure.Repositories;
|
using PowderCoating.Infrastructure.Repositories;
|
||||||
using PowderCoating.Infrastructure.Services;
|
using PowderCoating.Infrastructure.Services;
|
||||||
|
using PowderCoating.Infrastructure.Services.Columbia;
|
||||||
using PowderCoating.Application.Interfaces;
|
using PowderCoating.Application.Interfaces;
|
||||||
using PowderCoating.Application.Services;
|
using PowderCoating.Application.Services;
|
||||||
using PowderCoating.Application.Configuration;
|
using PowderCoating.Application.Configuration;
|
||||||
@@ -222,6 +223,9 @@ builder.Services.AddScoped<IInventoryAiLookupService, InventoryAiLookupService>(
|
|||||||
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
|
builder.Services.AddScoped<ICustomFormulaAiService, CustomFormulaAiService>();
|
||||||
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
|
builder.Services.AddScoped<IFormulaLibraryService, FormulaLibraryService>();
|
||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
|
builder.Services.AddScoped<IColumbiaCoatingsApiClient, ColumbiaCoatingsApiClient>();
|
||||||
|
builder.Services.AddScoped<IPowderCatalogUpsertService, PowderCatalogUpsertService>();
|
||||||
|
builder.Services.AddScoped<IColumbiaCatalogSyncService, ColumbiaCatalogSyncService>();
|
||||||
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
|
||||||
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
|
||||||
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
|
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
|
||||||
@@ -255,6 +259,7 @@ builder.Services.AddHostedService<StripeWebhookRetentionBackgroundService>();
|
|||||||
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
builder.Services.AddHostedService<SetupWizardReminderBackgroundService>();
|
||||||
builder.Services.AddHostedService<RecurringTransactionService>();
|
builder.Services.AddHostedService<RecurringTransactionService>();
|
||||||
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
builder.Services.AddHostedService<AppointmentReminderBackgroundService>();
|
||||||
|
builder.Services.AddHostedService<ColumbiaCatalogSyncBackgroundService>();
|
||||||
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
builder.Services.AddScoped<ISubscriptionService, SubscriptionService>();
|
||||||
builder.Services.AddScoped<IStripeService, StripeService>();
|
builder.Services.AddScoped<IStripeService, StripeService>();
|
||||||
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
builder.Services.AddScoped<IStripeConnectService, StripeConnectService>();
|
||||||
|
|||||||
@@ -565,7 +565,14 @@ public class QuickBooksOnlineService
|
|||||||
var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null;
|
var parentName = name.Contains(':') ? name.Substring(0, name.LastIndexOf(':')).Trim() : null;
|
||||||
|
|
||||||
result.TotalRecords++;
|
result.TotalRecords++;
|
||||||
rows.Add((displayName, parentName, number, desc, MapQboAccountType(typeStr), MapQboDetailType(detailType)));
|
// QBO Type (high-level) is reliable; DetailType often isn't mappable and falls back to
|
||||||
|
// Other. Reconcile so sub-type's parent always matches the type — otherwise an unmapped
|
||||||
|
// liability/equity/revenue would get an expense-range sub-type and post with the wrong sign.
|
||||||
|
var acctType = MapQboAccountType(typeStr);
|
||||||
|
var subType = MapQboDetailType(detailType);
|
||||||
|
if (AccountClassification.TypeForSubType(subType) != acctType)
|
||||||
|
subType = AccountClassification.DefaultSubTypeForType(acctType);
|
||||||
|
rows.Add((displayName, parentName, number, desc, acctType, subType));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pass 1: upsert every account WITHOUT parent links so they all get IDs.
|
// Pass 1: upsert every account WITHOUT parent links so they all get IDs.
|
||||||
|
|||||||
@@ -36,16 +36,34 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex justify-content-end mb-4">
|
@{
|
||||||
|
var tbNet = (decimal)(ViewBag.TrialBalanceNet ?? 0m);
|
||||||
|
var tbBalanced = Math.Abs(tbNet) < 0.01m;
|
||||||
|
}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
<div>
|
||||||
|
@if (tbBalanced)
|
||||||
|
{
|
||||||
|
<span class="badge bg-success-subtle text-success-emphasis border border-success-subtle py-2 px-3">
|
||||||
|
<i class="bi bi-check-circle me-1"></i>Trial balance: Balanced
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge bg-warning-subtle text-warning-emphasis border border-warning-subtle py-2 px-3"
|
||||||
|
title="Total debits minus total credits. A non-zero value usually means opening balances were entered without an offsetting entry, or a one-sided posting. Run Recalculate Balances; if it persists, review opening balances.">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>Trial balance off by @tbNet.ToString("C") (@(tbNet > 0 ? "excess debits" : "excess credits"))
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div></div>
|
||||||
|
}
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<form asp-action="FixOpeningBalanceSigns" method="post"
|
|
||||||
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks? This corrects negative balances caused by QB\'s sign convention.')">
|
|
||||||
@Html.AntiForgeryToken()
|
|
||||||
<button type="submit" class="btn btn-outline-warning"
|
|
||||||
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
|
|
||||||
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
|
<form id="recalcBalancesForm" asp-action="RecalculateBalances" method="post">
|
||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
|
<button type="button" id="btnRecalcBalances" class="btn btn-outline-secondary"
|
||||||
@@ -93,6 +111,80 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (Model.Any())
|
||||||
|
{
|
||||||
|
var revenueAccts = ViewBag.DefaultRevenueAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||||
|
var cogsAccts = ViewBag.DefaultCogsAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||||
|
var inventoryAccts = ViewBag.DefaultInventoryAccounts as List<Microsoft.AspNetCore.Mvc.Rendering.SelectListItem> ?? new();
|
||||||
|
int? selRevenue = ViewBag.DefaultRevenueAccountId as int?;
|
||||||
|
int? selCogs = ViewBag.DefaultCogsAccountId as int?;
|
||||||
|
int? selInventory = ViewBag.DefaultInventoryAccountId as int?;
|
||||||
|
|
||||||
|
<div class="card shadow-sm mb-4">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="card-title mb-0"><i class="bi bi-gear me-2 text-primary"></i>Default Accounts</h6>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#defaultAccountsBody">
|
||||||
|
<i class="bi bi-pencil me-1"></i>Set Defaults
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="defaultAccountsBody" class="collapse">
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small mb-3">
|
||||||
|
These accounts are used automatically when an item or invoice line doesn't specify one.
|
||||||
|
Leave any blank to keep the current behavior.
|
||||||
|
</p>
|
||||||
|
<form asp-action="SaveDefaultAccounts" method="post">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Revenue</label>
|
||||||
|
<select name="defaultRevenueAccountId" class="form-select">
|
||||||
|
<option value="">(No default — uses 4000)</option>
|
||||||
|
@foreach (var o in revenueAccts)
|
||||||
|
{
|
||||||
|
<option value="@o.Value" selected="@(selRevenue?.ToString() == o.Value)">@o.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Fallback revenue account for invoice lines.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">COGS</label>
|
||||||
|
<select name="defaultCogsAccountId" class="form-select">
|
||||||
|
<option value="">(No default)</option>
|
||||||
|
@foreach (var o in cogsAccts)
|
||||||
|
{
|
||||||
|
<option value="@o.Value" selected="@(selCogs?.ToString() == o.Value)">@o.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Pre-fills new inventory & catalog items.</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<label class="form-label fw-semibold">Inventory Asset</label>
|
||||||
|
<select name="defaultInventoryAccountId" class="form-select">
|
||||||
|
<option value="">(No default)</option>
|
||||||
|
@foreach (var o in inventoryAccts)
|
||||||
|
{
|
||||||
|
<option value="@o.Value" selected="@(selInventory?.ToString() == o.Value)">@o.Text</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<small class="form-text text-muted">Pre-fills new inventory items.</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-warning alert-permanent small mt-3 mb-3">
|
||||||
|
<i class="bi bi-exclamation-triangle me-1"></i>
|
||||||
|
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items post
|
||||||
|
inventory-consumption COGS (perpetual inventory). Leave these blank if you expense materials
|
||||||
|
when purchased.
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">
|
||||||
|
<i class="bi bi-check-lg me-1"></i>Save Defaults
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!Model.Any())
|
@if (!Model.Any())
|
||||||
{
|
{
|
||||||
<div class="card shadow-sm border-0">
|
<div class="card shadow-sm border-0">
|
||||||
|
|||||||
@@ -131,7 +131,7 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label asp-for="RevenueAccountId" class="form-label"></label>
|
<label asp-for="RevenueAccountId" class="form-label"></label>
|
||||||
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
|
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
|
||||||
<option value="">(Default revenue account)</option>
|
<option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
|
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<label asp-for="CogsAccountId" class="form-label"></label>
|
<label asp-for="CogsAccountId" class="form-label"></label>
|
||||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||||
<option value="">(Default COGS account)</option>
|
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
|
||||||
<option value="__new__">+ Add New Account…</option>
|
<option value="__new__">+ Add New Account…</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
||||||
|
|||||||
@@ -134,7 +134,7 @@
|
|||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label asp-for="RevenueAccountId" class="form-label"></label>
|
<label asp-for="RevenueAccountId" class="form-label"></label>
|
||||||
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
|
<select asp-for="RevenueAccountId" class="form-select" asp-items="ViewBag.RevenueAccounts">
|
||||||
<option value="">(Default revenue account)</option>
|
<option value="">@((ViewBag.HasDefaultRevenueAccount ?? false) ? "(Default revenue account)" : "(None)")</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
|
<small class="form-text text-muted">Account credited when this item is invoiced.</small>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
<label asp-for="CogsAccountId" class="form-label"></label>
|
<label asp-for="CogsAccountId" class="form-label"></label>
|
||||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||||
<option value="">(Default COGS account)</option>
|
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
|
||||||
<option value="__new__">+ Add New Account…</option>
|
<option value="__new__">+ Add New Account…</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
<small class="form-text text-muted">Account debited when materials are consumed.</small>
|
||||||
|
|||||||
@@ -618,6 +618,36 @@
|
|||||||
|
|
||||||
</div><!-- /tab-content -->
|
</div><!-- /tab-content -->
|
||||||
|
|
||||||
|
<!-- Maintenance Tools (SuperAdmin platform utilities) -->
|
||||||
|
<div class="card shadow-sm mt-4">
|
||||||
|
<div class="card-header bg-light">
|
||||||
|
<h6 class="card-title mb-0">
|
||||||
|
<i class="bi bi-tools me-2"></i>Maintenance Tools
|
||||||
|
</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-start border rounded p-3">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1"><i class="bi bi-sign-stop me-1"></i>Fix QuickBooks Import Signs</h6>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
Flips negative opening balances on Revenue, Liability, and Equity accounts to positive.
|
||||||
|
QuickBooks IIF exports store these credit-normal accounts as negative numbers; this
|
||||||
|
corrects them so the Chart of Accounts reads correctly. Run <strong>Recalculate Balances</strong>
|
||||||
|
on the company's Chart of Accounts afterward. Safe to run more than once.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form asp-action="FixOpeningBalanceSigns" asp-route-id="@Model.Id" method="post"
|
||||||
|
onsubmit="return confirm('Fix opening balance signs for Revenue, Liability, and Equity accounts imported from QuickBooks for this company?')">
|
||||||
|
@Html.AntiForgeryToken()
|
||||||
|
<button type="submit" class="btn btn-outline-warning ms-3 text-nowrap"
|
||||||
|
title="Fixes negative opening balances on Revenue/Liability/Equity accounts imported from QuickBooks IIF">
|
||||||
|
<i class="bi bi-sign-stop me-1"></i>Fix QB Import Signs
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Danger Zone (outside tabs — always present) -->
|
<!-- Danger Zone (outside tabs — always present) -->
|
||||||
<div class="card shadow-sm border-danger mt-4">
|
<div class="card shadow-sm border-danger mt-4">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
|
|||||||
@@ -1041,6 +1041,37 @@
|
|||||||
|
|
||||||
// Custom powder (no inventory item) â†' open modal to add to inventory
|
// Custom powder (no inventory item) â†' open modal to add to inventory
|
||||||
if (!hasInv) {
|
if (!hasInv) {
|
||||||
|
// If the powder is already in the master catalog, receive it straight to inventory
|
||||||
|
// with all its specs/docs — no modal. Only fall back to the modal when it isn't.
|
||||||
|
const tokenAuto = document.querySelector('input[name="__RequestVerificationToken"]')?.value
|
||||||
|
?? document.querySelector('meta[name="__RequestVerificationToken"]')?.content;
|
||||||
|
this.disabled = true; qtyInput.disabled = true;
|
||||||
|
this.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||||||
|
try {
|
||||||
|
const autoResp = await fetch('@Url.Action("ReceivePowderFromCatalog", "Dashboard")', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'RequestVerificationToken': tokenAuto },
|
||||||
|
body: `coatId=${coatId}&lbsReceived=${lbs}`
|
||||||
|
});
|
||||||
|
const autoData = await autoResp.json();
|
||||||
|
if (autoData.success) {
|
||||||
|
fadePlacedRow(row);
|
||||||
|
showInventoryToast('Added "' + (autoData.itemName || 'powder') + '" to inventory from the catalog.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!autoData.needsDetails) {
|
||||||
|
alert(autoData.message || 'Could not record receipt. Please try again.');
|
||||||
|
this.disabled = false; qtyInput.disabled = false;
|
||||||
|
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Not in catalog — fall through to the manual entry modal.
|
||||||
|
} catch {
|
||||||
|
// Network error — fall back to the manual entry modal.
|
||||||
|
}
|
||||||
|
this.disabled = false; qtyInput.disabled = false;
|
||||||
|
this.innerHTML = '<i class="bi bi-box-arrow-in-down"></i> Got It';
|
||||||
|
|
||||||
const modal = document.getElementById('addPowderModal');
|
const modal = document.getElementById('addPowderModal');
|
||||||
// Pre-fill hidden + text fields
|
// Pre-fill hidden + text fields
|
||||||
modal.querySelector('#apm-coatId').value = coatId;
|
modal.querySelector('#apm-coatId').value = coatId;
|
||||||
|
|||||||
@@ -138,6 +138,18 @@
|
|||||||
<li class="mb-1">If a vendor name is selected in the Vendor field before searching, results are scoped to that vendor first, then broadened automatically if nothing matches.</li>
|
<li class="mb-1">If a vendor name is selected in the Vendor field before searching, results are scoped to that vendor first, then broadened automatically if nothing matches.</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<div class="alert alert-permanent alert-success d-flex gap-2 mb-3" role="alert">
|
||||||
|
<i class="bi bi-cloud-check me-1 flex-shrink-0 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
<strong>Columbia Coatings integration:</strong> the catalog is connected directly to the Columbia Coatings
|
||||||
|
product catalog and refreshes regularly (near real-time). Columbia powders auto-fill their full specs, cure
|
||||||
|
schedule, and SDS/TDS links, and their prices stay current — quotes use the latest catalog price even
|
||||||
|
when your stored cost is older. An item’s detail page shows the current catalog price and flags when it
|
||||||
|
has changed since you last bought it. Discontinued powders are flagged “cannot reorder” but stay
|
||||||
|
usable for stock you already have.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-camera me-1"></i>Label Scanner (Camera)</h3>
|
<h3 class="h6 fw-semibold mt-4 mb-2"><i class="bi bi-camera me-1"></i>Label Scanner (Camera)</h3>
|
||||||
<p>
|
<p>
|
||||||
Click the <strong>camera icon</strong> next to the Lookup button to open the label scanner.
|
Click the <strong>camera icon</strong> next to the Lookup button to open the label scanner.
|
||||||
|
|||||||
@@ -237,6 +237,40 @@
|
|||||||
The chart of accounts is typically configured once during initial setup. You can add new accounts
|
The chart of accounts is typically configured once during initial setup. You can add new accounts
|
||||||
at any time if your accounting needs expand.
|
at any time if your accounting needs expand.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<h3 class="h6 fw-semibold mt-3 mb-2">Default Accounts</h3>
|
||||||
|
<p>
|
||||||
|
The <strong>Default Accounts</strong> card at the top of the Chart of Accounts page lets you choose a
|
||||||
|
default <strong>Revenue</strong>, <strong>COGS</strong>, and <strong>Inventory Asset</strong> account
|
||||||
|
for your company. These are used automatically when an item or invoice line doesn't specify its own:
|
||||||
|
</p>
|
||||||
|
<ul class="mb-3">
|
||||||
|
<li class="mb-1"><strong>Revenue</strong> — invoice lines without a specific revenue account fall back to this one (and to account 4000 if you haven't set a default).</li>
|
||||||
|
<li class="mb-1"><strong>COGS</strong> and <strong>Inventory Asset</strong> — new inventory and catalog items are pre-filled with these, so you don't have to pick them every time. You can still change or clear them on each item.</li>
|
||||||
|
</ul>
|
||||||
|
<p>
|
||||||
|
Leave any of them blank to keep the current behavior. Click <strong>Set Defaults</strong> to expand the
|
||||||
|
card, choose your accounts, and save.
|
||||||
|
</p>
|
||||||
|
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-3" role="alert">
|
||||||
|
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||||
|
<div>
|
||||||
|
Setting <strong>both</strong> a COGS and an Inventory Asset default makes new items record
|
||||||
|
inventory-consumption cost (COGS) to the general ledger as they're used — this is
|
||||||
|
perpetual-inventory accounting. If you expense materials when you <em>purchase</em> them, leave
|
||||||
|
these two blank to avoid double-counting.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="h6 fw-semibold mt-3 mb-2">Trial Balance Indicator</h3>
|
||||||
|
<p>
|
||||||
|
The top of the Chart of Accounts page shows a <strong>Trial balance</strong> badge. When total
|
||||||
|
debits equal total credits it reads <em>Balanced</em>; otherwise it shows how far off the books
|
||||||
|
are (excess debits or credits). A non-zero value usually means opening balances were entered
|
||||||
|
without an offsetting entry, or a one-sided posting occurred. Run <strong>Recalculate Balances</strong>
|
||||||
|
first; if it persists, review your opening balances or ask your accountant.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
<div class="alert alert-permanent alert-warning d-flex gap-2 mb-0" role="alert">
|
||||||
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
<i class="bi bi-exclamation-triangle-fill flex-shrink-0 mt-1"></i>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -17,8 +17,10 @@
|
|||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<form asp-action="Create" method="post">
|
<form asp-action="Create" method="post" id="inventory-create-form">
|
||||||
|
<input type="hidden" asp-for="DuplicateOverrideInventoryItemId" id="duplicate-override-id" />
|
||||||
<partial name="_ValidationSummary" />
|
<partial name="_ValidationSummary" />
|
||||||
|
<div id="inventory-duplicate-status" class="d-none mb-3" role="alert"></div>
|
||||||
|
|
||||||
<!-- Basic Information -->
|
<!-- Basic Information -->
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
@@ -373,7 +375,7 @@
|
|||||||
<label asp-for="InventoryAccountId" class="form-label"></label>
|
<label asp-for="InventoryAccountId" class="form-label"></label>
|
||||||
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
|
<select asp-for="InventoryAccountId" class="form-select" asp-items="ViewBag.InventoryAccounts"
|
||||||
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
|
data-quick-add-url="/Accounts/Create?preSubType=4" data-quick-add-title="Add Inventory Account">
|
||||||
<option value="">(Default inventory account)</option>
|
<option value="">@((ViewBag.HasDefaultInventoryAccount ?? false) ? "(Default inventory account)" : "(None)")</option>
|
||||||
<option value="__new__">+ Add New Account…</option>
|
<option value="__new__">+ Add New Account…</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
|
<small class="form-text text-muted">Asset account where inventory value is tracked (e.g., 1200 Inventory - Powder).</small>
|
||||||
@@ -382,7 +384,7 @@
|
|||||||
<label asp-for="CogsAccountId" class="form-label"></label>
|
<label asp-for="CogsAccountId" class="form-label"></label>
|
||||||
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
<select asp-for="CogsAccountId" class="form-select" asp-items="ViewBag.CogsAccounts"
|
||||||
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
data-quick-add-url="/Accounts/Create?preSubType=40" data-quick-add-title="Add COGS Account">
|
||||||
<option value="">(Default COGS account)</option>
|
<option value="">@((ViewBag.HasDefaultCogsAccount ?? false) ? "(Default COGS account)" : "(None)")</option>
|
||||||
<option value="__new__">+ Add New Account…</option>
|
<option value="__new__">+ Add New Account…</option>
|
||||||
</select>
|
</select>
|
||||||
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
|
<small class="form-text text-muted">Expense account debited when this material is consumed on a job.</small>
|
||||||
@@ -428,16 +430,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
<partial name="_LabelScanModal" />
|
||||||
{
|
|
||||||
<partial name="_LabelScanModal" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<partial name="_ValidationScriptsPartial" />
|
<partial name="_ValidationScriptsPartial" />
|
||||||
<script>const inventoryFormIsCreate = true;</script>
|
<script>const inventoryFormIsCreate = true;</script>
|
||||||
|
<script src="~/js/inventory-vendor-match.js"></script>
|
||||||
<partial name="_InventoryColorFamilyScripts" />
|
<partial name="_InventoryColorFamilyScripts" />
|
||||||
<script src="~/js/inventory-catalog-lookup.js"></script>
|
<script src="~/js/inventory-catalog-lookup.js"></script>
|
||||||
|
<script src="~/js/inventory-duplicate-check.js"></script>
|
||||||
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
@if ((bool)(ViewBag.AiInventoryAssistEnabled ?? false))
|
||||||
{
|
{
|
||||||
<script src="~/js/inventory-label-scan.js"></script>
|
<script src="~/js/inventory-label-scan.js"></script>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user