Estimator Estimates Module
The CrestApps.OrchardCore.Estimator.Estimates module provides estimates and the estimate workspace. An estimate prices a project; its lines are catalog items, assemblies or manual lines, and each line is calculated with the catalog, the labor model and the factor stack as soon as it is saved.
| Feature ID | Name | Category | Dependencies |
|---|---|---|---|
CrestApps.OrchardCore.Estimator.Estimates | Estimator Estimates | Estimator | CrestApps.OrchardCore.Estimator, CrestApps.OrchardCore.Estimator.Catalog, CrestApps.OrchardCore.Estimator.Takeoff |
The Estimator setup recipe enables the feature. The module adds no permissions: viewing the estimates of a project needs CanViewAsync on the project, and changing them needs CanManageAsync, the same project access as the Estimator module. The feature depends on the catalog because every line is priced and labored from it, and on the takeoff because lines take their quantities from takeoff items.
Data model
All documents are in the Estimator YesSql collection.
| Document | One per | Holds |
|---|---|---|
Estimate | Estimate of a project | ProjectId, BidId, Name, Description, CurrentRevisionId, CreatedByUserId, CreatedUtc, ModifiedUtc |
EstimateRevision | Revision of an estimate | EstimateId, ProjectId, Number, Label, PricingDate, LaborColumn, LaborRate, LaborRateSource, Factors, Breakdown, LastLineNumber, the totals (LineCount, Material, LaborHours, Labor), CalculatedUtc |
QuotePackage | Package that vendors or subcontractors quote | RevisionId, EstimateId, ProjectId, Kind (Material or Subcontract), Name, Description, BreakdownNodeId, CostCode, PlaceholderAmount, ScopeItems (Id, Text), LastScopeNumber, SelectedQuoteId |
VendorQuote | Quote of a package | PackageId, RevisionId, Vendor, ContactName, QuoteNumber, ReceivedUtc, ValidUntil, Amount, Freight, Tax, Adjustments (Name, Amount), Scope (per scope item: Status, PlugAmount, Note), Inclusions, Exclusions, Notes |
EstimateLineChunk | Up to 250 lines of one breakdown node | RevisionId, BreakdownNodeId, NodeOrder, ChunkNumber, Lines (see Estimator module) |
A new estimate gets revision 1, labelled Base. Epic 1.10 adds the revision lifecycle (review, approval, locking and the difference between revisions).
| Index table | One row per | Columns |
|---|---|---|
EstimateIndex | Estimate | ProjectId, BidId, Name |
EstimateRevisionIndex | Revision | EstimateId, ProjectId, Number |
QuotePackageIndex | Quote package | RevisionId, ProjectId, Name, Kind |
VendorQuoteIndex | Vendor quote | PackageId, RevisionId, Vendor |
The EstimateMigrations data migration creates them (version 2 adds the quote tables). The line index tables belong to the base Estimator feature. The design is recorded in docs/engineering/adr/0007-estimate-workspace.md in the repository.
Breakdown
EstimateRevision.Breakdown is an EstimateBreakdown: a flat list of BreakdownNode (Id, ParentId, Name, Kind, Order, Factors) and LastNodeNumber. Node identifiers are short (N1, N2) and never reused, so the lines of a removed node never land on a new one. BreakdownTree adds, moves, re-parents (never under the node itself) and removes nodes, lists them depth first (Flatten) and gives paths, ancestors and descendants.
BreakdownNodeKind | |
|---|---|
Building, Level, Area | Where the work is |
System, Phase, Other | What or when the work is |
Lines
EstimateLine has the line identifier (L1, L2; never reused, from LastLineNumber), the BreakdownNodeId, and:
| Group | Properties |
|---|---|
| What it prices | Kind (Item, Assembly, Manual), ItemCode or AssemblyCode, Variant, Parameters |
| Input | Quantity, Unit, UnitCost and UnitHours (manual lines), CostCode, LaborCategory, Factor and FactorReason, TakeoffItemId and TakeoffLocation, Notes |
| Calculated | Description and Unit (from the catalog for item and assembly lines), Material, LaborHours, Labor, Warnings |
EstimateLineRules.Normalize trims text, keeps only the code of the line's kind and drops nameless parameters. EstimateLineRules.Validate refuses a line with an EstimateLineRuleProblem: no code (CodeRequired), a manual line without a description, a quantity, unit cost or unit hours below zero, an unknown breakdown node, a line factor of zero or below, a line factor other than 1 without a reason, or an unknown line identifier.
EstimateLineWarnings are flags of what the calculation could not find: ItemNotFound, AssemblyNotFound, PriceMissing, LaborMissing, FormulaFailed and Cycle. A line with warnings is saved; what is missing costs nothing.
Calculation
EstimateLineCalculator.CalculateAsync(revision, lines) loads one CatalogSnapshot for all the lines (AssemblyCalculator.LoadAsync): the assemblies of the lines and every assembly under them, the items they and the lines use, and the prices in effect on the pricing date. Each line is then calculated from the snapshot, so a call reads the database once whatever the number of lines.
| Kind | Material | Labor hours |
|---|---|---|
Item | LineCalculator with the item's price, waste and package rounding | The item's labor unit in the revision's column, times the factors |
Assembly | The expanded items, each as an item line | Each item's labor unit, or the assembly's own labor unit for assemblies with own labor, times the factors |
Manual | Quantity × UnitCost | Quantity × UnitHours, times the factors |
The labor cost is the hours times LaborRate. The factor stack of each item is built by LaborFactorSet:
- The estimate factors of the revision (
Factorswith scopeEstimate). - The factors of the line's breakdown node and of every node above it (scope
Breakdown). - The category factors of the revision whose
LaborCategoryis the item's labor category (scopeCategory). - The line factor, with its reason (scope
Line).
EstimateLineCalculator.ExplainAsync(revision, line) returns an EstimateLineExplanation: each component with its engine TraceEntry steps (installed quantity, waste, purchase rounding, material, base hours, each factor, adjusted hours, labor), the labor rate and its source, and the missing items and assemblies. The line is not changed.
AssemblyCalculator.Calculate(snapshot, request) and CalculateItem(snapshot, ...) are the snapshot versions of the assembly test panel's CalculateAsync. AssemblyCalculationRequest has Variant, Factors and IncludeTrace.
Quantities from the takeoff
A line with a TakeoffItemId takes the quantity of that takeoff item: over every sheet when TakeoffLocation is empty, or on the sheets of that location (matched ignoring case). EstimateTakeoff binds them:
| Method | What it does |
|---|---|
SummarizeAsync(projectId) | The takeoff summary of the project (TakeoffSummary.Summarize), per item and per location |
QuantityOf(summary, takeoffItemId, location) | The quantity; 0 for a location without marks of the item; null for an unknown item |
AddAsync(revision, lines) | Adds EstimateTakeoffLines (takeoff item, location, kind, code, node) with the current quantity, through SaveLinesAsync. An unknown takeoff item is refused with UnknownTakeoffItem |
RefreshAsync(revision) | Gives every bound line the current quantity of its item and calculates the lines that changed. Returns a TakeoffRefreshResult with the lines updated, and the lines whose item is gone (they keep their quantity) |
Recap and alternates
The design is recorded in docs/engineering/adr/0009-recap-and-alternates.md in the repository. EstimateRevision.Recap holds the EstimateRecapSettings in percent: MaterialTaxPercent, OverheadPercent, OverheadPerLaborHour, ProfitMethod, ProfitPercent, MarkupByCostType with CostTypeMarkups, ContingencyPercent, BondTiers (UpTo, RatePercent) and RoundTo. EstimateRecap.ToEngine turns them into the engine's RecapSettings, and EstimateRecap.Validate refuses negative values, a margin of 100% or more, markups by cost type with the margin method, bond tiers out of order or with a rate of 100% or more, and a rounding amount of zero or less (EstimateRecapProblem).
A manual line has a CostType (Material, Equipment, DirectJobExpense, Allowance); item and assembly lines are material. A line can belong to an alternate (AlternateId) of EstimateRevision.Alternates (Id such as A1, never reused; Code, Description, Kind Add or Deduct). A line that names an unknown alternate is refused with UnknownAlternate.
The line index has AlternateId and CostType columns (the base module's EstimateLineMigrations version 2), and IEstimateLineStore.GetGroupedTotalsAsync totals the lines by alternate and cost type with one SQL GROUP BY. EstimateRecap.Calculate(revision, groups) maps the groups to the engine's CostTotals (equipment, direct job expenses as Other, allowances; quotes and subcontracts from the revision) and gives an EstimateRecapResult:
- The base bid: the lines without an alternate, of an unknown alternate, and of deduct alternates.
- Each alternate's
EstimateAlternatePrice: the selling price before rounding with its lines added (add) or taken out (deduct), minus the base bid's before rounding. So the overhead, profit, contingency and bond follow the alternate.
EstimateWorkspace keeps EstimateRevision.SellingPrice (the base bid) up to date on every line save, quote change and recap change, and has SaveRecapAsync, GetRecapAsync, SolveTargetAsync, SaveAlternateAsync and RemoveAlternateAsync (the alternate's lines go to the base bid).
Quotes and subcontracts
The design is recorded in docs/engineering/adr/0008-quotes-and-subcontracts.md in the repository. A quote package carries an amount into the revision: the leveled total of its selected quote, or its PlaceholderAmount. EstimateRevision.QuoteAmount sums the material packages and SubcontractAmount the subcontracts; the workspace adds them to the total.
QuoteLeveling levels quotes so they compare on the same scope:
| Method | What it does |
|---|---|
Level(package, quote) | A QuoteLevelingResult: the amount, the freight and tax, the sum of the adjustments, the sum of the plugs of the scope items the quote excludes or does not state, the leveled Total, and the UnpluggedScopeItems (gaps without a plug). A response to a scope item the package no longer has is ignored; an included item's plug is ignored |
Compare(package, quotes) | A QuoteComparison: each quote with its result and how much it is above the low quote, and the LowQuoteId (the first of equal totals) |
Carried(package, quotes) | The selected quote's leveled total, or the placeholder (also when the selected quote is gone) |
QuoteRules.Normalize trims texts, drops empty adjustments and gives new scope items an identifier (S1, S2) that is never reused. QuoteRules.Validate refuses a package without a name, with a placeholder below zero, or with scope items that are empty or have the same text (ignoring case); and a quote without a vendor, with an amount, freight or tax below zero, an adjustment without a name, or a plug below zero (QuoteRuleProblem). Adjustments can be below zero (deducts).
EstimateQuotes keeps them and the totals: SavePackageAsync, SaveQuoteAsync (a new quote gets its received day), SelectAsync (or clears the selection with null), DeleteQuoteAsync (clears the selection when it was selected) and DeletePackageAsync (deletes the quotes, and the lines it covered carry their material again). Each saves the revision's quote totals (EstimateWorkspace.SaveQuoteTotalsAsync).
A line with a QuotePackageId of a material package of the revision is covered: it keeps its labor, carries no material, and its explanation gives MaterialInQuote. A line that names another package is refused with UnknownQuotePackage. Deleting an estimate deletes its packages and quotes.
Services
| Service | Lifetime | What it does |
|---|---|---|
IEstimateStore (EstimateStore) | Scoped | The estimates of a project, in name order |
IEstimateRevisionStore (EstimateRevisionStore) | Scoped | The revisions of an estimate or a project |
EstimateLineCalculator | Scoped | Calculates and explains lines |
EstimateTakeoff | Scoped | Binds lines to takeoff items (see above) |
IQuotePackageStore (QuotePackageStore), IVendorQuoteStore (VendorQuoteStore) | Scoped | The packages of a revision, by name; the quotes of a package, by vendor, or of a revision |
EstimateQuotes | Scoped | Keeps packages, quotes, the selection and the quote totals (see above) |
EstimateWorkspace | Scoped | CreateAsync (with revision 1 and the labor of the job conditions), SaveLinesAsync, RecalculateAsync (every line or the lines of some nodes), RefreshLaborAsync, RemoveNodeAsync (its lines move to the parent node), DeleteAsync, DeleteByProjectAsync |
SaveLinesAsync(revision, changes, deletes) normalizes and validates each change, calculates the accepted ones, gives new lines their identifier, writes only the chunks that hold them (IEstimateLineStore.UpdateAsync), deletes lines, and saves the revision totals from the line index. It returns an EstimateLineSaveOutcome per change, in order: the saved line, or the problems.
A new estimate takes LaborRate from the composite rate of the project's job conditions (ProjectLaborSettings.CompositeRate), LaborRateSource from its crew and rate table, and the estimate and category factors. Breakdown factors are set on the nodes.
The base module's IEstimateLineStore gains FindAsync(revisionId, lineIds) and LoadByNodesAsync(revisionId, nodeIds), and GetTotalsAsync counts the changes of the current session.
Admin routes
| Route | Action |
|---|---|
Admin/estimator/estimates/Index?projectId= | The estimates of a project (AdminList) |
Admin/estimator/estimates/Create?projectId=, Edit/{estimateId}, Delete/{estimateId} | Add, change and delete an estimate |
Admin/estimator/estimates/Settings/{estimateId}, RefreshLabor/{estimateId} | Pricing date, labor column and rate; take the job conditions again |
Admin/estimator/workspace/Index/{estimateId} | The workspace of the current revision |
Admin/estimator/estimate-takeoff/Index/{estimateId}, Refresh/{estimateId} | Add lines from the takeoff items of the project; update the quantities of bound lines |
Admin/estimator/recap/Index/{estimateId} | The recap, the alternates, price to target and the recap settings |
Admin/estimator/recap/Target/{estimateId}, ApplyProfit/{estimateId}, SaveAlternate/{estimateId}, RemoveAlternate/{estimateId} | Solve the profit of a target price and take it; add, change and remove alternates |
Admin/estimator/quotes/Index/{estimateId} | The quote packages of an estimate (AdminList) |
Admin/estimator/quotes/Create/{estimateId}?kind=, Edit/{packageId}, Delete/{packageId} | Add, change and delete a package |
Admin/estimator/quotes/Level/{packageId}, QuickAdd/{packageId}, Select/{packageId} | The leveling page; a quick quote (vendor and amount); select or unselect a quote |
Admin/estimator/quotes/AddQuote/{packageId}, EditQuote/{quoteId}, DeleteQuote/{quoteId} | Add, change and delete a vendor quote |
The workspace script calls JSON actions of WorkspaceController, with the revision identifier. POST actions need the antiforgery token in the RequestVerificationToken header.
| Action | Method | Body or query | Answer |
|---|---|---|---|
Data/{revisionId} | GET | revision (totals and settings), breakdown (nodes in tree order with depth and factors), takeoffItems (names by identifier), quotePackages (the material packages), alternates, fields and lines | |
Save/{revisionId} | POST | { changes: [line], deletes: [lineId] } | outcomes (per change: key, line or problems), deleted, revision |
Explain/{revisionId}?lineId= | GET | The explanation of the line | |
Breakdown/{revisionId} | POST | { action, nodeId, parentId, name, kind, factors }; action is add, update, remove, up, down or parent | breakdown, revision, and reload when lines were calculated again |
Search/{revisionId}?term= | GET | At least two characters | Up to 8 assemblies and 8 active items: kind, code, desc, unit |
Assembly/{revisionId}?code= | GET | The parameters and variants of an assembly |
The lines of Data and Save are arrays of values in the order of fields (EstimateLineJson.Fields), which keeps the names out of every row: 100,000 lines are about 16 MB. The browser sends lines as objects with the same names.
The workspace script
Assets/js/estimate-workspace.js loads every line once and keeps it in the browser. The grid draws only the rows on the screen, from a pool of row elements moved into place with a transform as it scrolls; rows have a fixed height of 30 pixels. Edits are queued and sent in one Save call a quarter of a second after the last one; a new line waits for its identifier before it is sent again. The pure parts (decoding lines, parsing numbers, ordering by the tree, totals per node, keyboard moves, selection, bulk changes, the visible range) are in Assets/js/estimate-grid.js, tested in tests/js/estimate-grid.test.mjs.
Performance
Measured on SQLite with 100,002 lines on a development machine:
| Operation | Time |
|---|---|
| Save and calculate 5,000 new lines | About 0.75 s |
Load every line (Data, 16 MB) | 0.4 s |
| Save one changed line | 72 ms |
| Explain a line | 6 ms |
| Jump the grid 20 times through the lines | 16 ms |
| Filter the lines by text | Under 0.2 s |
Clean-up handler
EstimateProjectEventHandler implements IProjectEventHandler.DeletingAsync(project) and deletes the project's estimates with their revisions and lines.
Tests
tests/CrestApps.OrchardCore.Estimator.Tests/Estimates holds the tests of the tree (BreakdownTreeTests), the line rules, the calculator, the workspace, the takeoff binding, the quote leveling, rules and totals, the recap and the alternates (on real SQLite, with a small electrical catalog in Utilities/EstimateTestCatalog.cs), and the line store.