Skip to main content
Technical ManualLooking for step-by-step instructions? In the User Manual: EstimatesEstimate WorkspaceQuotes and SubcontractsRecap and Alternates

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 IDNameCategoryDependencies
CrestApps.OrchardCore.Estimator.EstimatesEstimator EstimatesEstimatorCrestApps.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.

DocumentOne perHolds
EstimateEstimate of a projectProjectId, BidId, Name, Description, CurrentRevisionId, CreatedByUserId, CreatedUtc, ModifiedUtc
EstimateRevisionRevision of an estimateEstimateId, ProjectId, Number, Label, PricingDate, LaborColumn, LaborRate, LaborRateSource, Factors, Breakdown, LastLineNumber, the totals (LineCount, Material, LaborHours, Labor), CalculatedUtc
QuotePackagePackage that vendors or subcontractors quoteRevisionId, EstimateId, ProjectId, Kind (Material or Subcontract), Name, Description, BreakdownNodeId, CostCode, PlaceholderAmount, ScopeItems (Id, Text), LastScopeNumber, SelectedQuoteId
VendorQuoteQuote of a packagePackageId, RevisionId, Vendor, ContactName, QuoteNumber, ReceivedUtc, ValidUntil, Amount, Freight, Tax, Adjustments (Name, Amount), Scope (per scope item: Status, PlugAmount, Note), Inclusions, Exclusions, Notes
EstimateLineChunkUp to 250 lines of one breakdown nodeRevisionId, 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 tableOne row perColumns
EstimateIndexEstimateProjectId, BidId, Name
EstimateRevisionIndexRevisionEstimateId, ProjectId, Number
QuotePackageIndexQuote packageRevisionId, ProjectId, Name, Kind
VendorQuoteIndexVendor quotePackageId, 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, AreaWhere the work is
System, Phase, OtherWhat or when the work is

Lines​

EstimateLine has the line identifier (L1, L2; never reused, from LastLineNumber), the BreakdownNodeId, and:

GroupProperties
What it pricesKind (Item, Assembly, Manual), ItemCode or AssemblyCode, Variant, Parameters
InputQuantity, Unit, UnitCost and UnitHours (manual lines), CostCode, LaborCategory, Factor and FactorReason, TakeoffItemId and TakeoffLocation, Notes
CalculatedDescription 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.

KindMaterialLabor hours
ItemLineCalculator with the item's price, waste and package roundingThe item's labor unit in the revision's column, times the factors
AssemblyThe expanded items, each as an item lineEach item's labor unit, or the assembly's own labor unit for assemblies with own labor, times the factors
ManualQuantity × UnitCostQuantity × UnitHours, times the factors

The labor cost is the hours times LaborRate. The factor stack of each item is built by LaborFactorSet:

  1. The estimate factors of the revision (Factors with scope Estimate).
  2. The factors of the line's breakdown node and of every node above it (scope Breakdown).
  3. The category factors of the revision whose LaborCategory is the item's labor category (scope Category).
  4. 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:

MethodWhat 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:

MethodWhat 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​

ServiceLifetimeWhat it does
IEstimateStore (EstimateStore)ScopedThe estimates of a project, in name order
IEstimateRevisionStore (EstimateRevisionStore)ScopedThe revisions of an estimate or a project
EstimateLineCalculatorScopedCalculates and explains lines
EstimateTakeoffScopedBinds lines to takeoff items (see above)
IQuotePackageStore (QuotePackageStore), IVendorQuoteStore (VendorQuoteStore)ScopedThe packages of a revision, by name; the quotes of a package, by vendor, or of a revision
EstimateQuotesScopedKeeps packages, quotes, the selection and the quote totals (see above)
EstimateWorkspaceScopedCreateAsync (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​

RouteAction
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.

ActionMethodBody or queryAnswer
Data/{revisionId}GETrevision (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=GETThe explanation of the line
Breakdown/{revisionId}POST{ action, nodeId, parentId, name, kind, factors }; action is add, update, remove, up, down or parentbreakdown, revision, and reload when lines were calculated again
Search/{revisionId}?term=GETAt least two charactersUp to 8 assemblies and 8 active items: kind, code, desc, unit
Assembly/{revisionId}?code=GETThe 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:

OperationTime
Save and calculate 5,000 new linesAbout 0.75 s
Load every line (Data, 16 MB)0.4 s
Save one changed line72 ms
Explain a line6 ms
Jump the grid 20 times through the lines16 ms
Filter the lines by textUnder 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.