Skip to main content
Technical ManualLooking for step-by-step instructions? In the User Manual: CatalogPricesAssembliesPrice UpdatesLabor Rates and CrewsLabor FactorsJob Conditions Wizard

Estimator Catalog Module

The CrestApps.OrchardCore.Estimator.Catalog module provides the catalog that estimates are priced from: the items, the history of their prices, their labor units in three difficulty columns, the assemblies built from them, price file imports through Data Pipelines, and the price update report.

Feature IDNameCategoryDependencies
CrestApps.OrchardCore.Estimator.CatalogEstimator CatalogEstimatorCrestApps.OrchardCore.Estimator

The Estimator setup recipe enables the feature, with OrchardCore.DataPipelines and OrchardCore.Media for price file imports. The Save catalog items step is registered only while OrchardCore.DataPipelines is enabled (DataPipelinesStartup, [RequireFeatures]).

Permissions​

PermissionName in the adminGrants
ManageEstimatorCatalogManage catalog itemsAdd, change and delete items; run imports
ManageEstimatorPricesManage prices and price filesAdd prices; imports keep the prices of their rows
ManageEstimatorLaborUnitsManage labor unitsChange labor units; imports keep the labor columns of their rows
ManageEstimatorAssembliesManage assembliesAdd, change and delete assemblies
ManageEstimatorLaborRatesManage labor rates, crews and factorsAdd, change and delete rate tables, crews and labor factors
ViewEstimatorCatalogView the catalog and pricesSee the catalog, prices, assemblies and price updates, and test assemblies. Implied by the five permissions above

The Administrator role has every permission. CatalogAccess works out the rights of the user once per request (CatalogRights).

Data model​

All documents are in the Estimator YesSql collection.

DocumentOne perHolds
CatalogEntryItemItemNumber, Industry, Kind, Description, Manufacturer, CatalogNumber, Upc, Category, MasterFormatSection, Unit (a unit code of IUnitRegistry), PricePer (QuantityBasis: E, C, M), PackageSize, WastePercent, LaborCategory, LaborUnits, Status, ReplacementItemNumber, Notes
PriceRecordPriceCatalogEntryId, ItemNumber, Price, PricePer, EffectiveUtc (a day at midnight UTC), Source (Manual, PriceFile, Vendor, Quote), Vendor, ImportBatchId, PreviousPrice, PreviousPricePer, Notes, CreatedUtc, CreatedByUserId
CatalogImportBatchImportName, Source, Vendor, EffectiveUtc, the counts (RowCount, CreatedCount, UpdatedCount, UnchangedCount, RejectedCount, PricesAddedCount, PriceIncreaseCount, PriceDecreaseCount), the first 100 Problems, CreatedUtc, CompletedUtc, CreatedByUserId

The model is named CatalogEntry because CatalogItem is the CrestApps base class of every stored record. A CatalogLaborUnit (in CatalogEntry.LaborUnits) has a Table (Standard by default), Normal, Difficult and VeryDifficult hours, and a Basis. An item has at most one labor unit per table.

Prices are append-only. A price record keeps the price in effect on its day before it was added, so a price update can be compared without reading the history again.

Index tableOne row perColumns
CatalogEntryIndexItemItemNumber, NormalizedItemNumber (trimmed, upper case), Description (first 500 characters), Manufacturer, CatalogNumber, Category, Kind, Status
PriceRecordIndexPriceCatalogEntryId, ItemNumber, EffectiveUtc, ImportBatchId, Direction (New, Increase, Decrease, Same), ChangeHundredths (the change per unit in hundredths of a percent, an integer so it sorts the same on every database), CreatedUtc
CatalogImportBatchIndexImportName, CreatedUtc

The CatalogMigrations data migration creates them. The design is recorded in docs/engineering/adr/0004-catalog-and-prices.md in the repository.

Rules​

CatalogRules.Validate checks an item: an item number of at most 64 characters, unique ignoring case and surrounding spaces; a description; a unit the IUnitRegistry knows; waste from 0 to 100 percent; a package size above 0 or none; labor hours of 0 or more; one labor unit per table. A price is 0 or more and below one billion.

PriceHistory.Current(records, day) gives the price in effect on a day: the latest EffectiveUtc on or before it; of two on the same day, the one added last. Prices compare per one unit (UnitPrice = Price / basis), so 45.00 per hundred and 400.00 per thousand compare as 0.45 and 0.40. CatalogValues reads the numbers ($1,234.50), bases (E, EA, C, 100, M, 1000), dates (2026-11-01, 11/1/2026) and percentages of price files with the invariant culture.

Services​

ServiceLifetimeWhat it does
ICatalogEntryStore (CatalogEntryStore)ScopedPages and searches items (LIKE on the item number, description, manufacturer and catalog number), finds them by item number in batches of 500, and lists the categories with one SELECT DISTINCT
IPriceRecordStore (PriceRecordStore)ScopedThe prices of an item, the price in effect of each item of a page (FindCurrentAsync), and the prices of an import filtered on Direction and sorted by the change
ICatalogImportBatchStore (CatalogImportBatchStore)ScopedThe imports, the latest first
CatalogImporterScopedImports a chunk of CatalogImportRows: loads the items and prices of the chunk together, checks each row on a copy of its item, and adds the counts and problems to the CatalogImportBatch
CatalogImportRunnerScopedImports rows of any number in chunks of 500, each in a child shell scope (ShellScope.UsingChildScopeAsync) that commits it, then saves the batch
IUnitRegistrySingletonThe units of measure of CrestApps.Estimator.Units (UnitRegistry.CreateDefault()), registered with TryAddSingleton so a feature can register its own first

How an import changes the catalog​

CatalogImportOptions sets the Source, Vendor, EffectiveUtc, LaborTable, CreateMissing, ApplyPrices and ApplyLabor. For each row:

  1. A row without an item number is refused (ItemNumberMissing). An unknown item is refused when CreateMissing is off (NotInCatalog).
  2. The values that are not text are read. A value that cannot be read refuses the row (InvalidPrice, InvalidPricePer, InvalidLabor, InvalidDate, InvalidNumber, InvalidStatus).
  3. The non-empty cells are applied to a copy of the item, which CatalogRules checks (InvalidItem, with the first CatalogRuleProblem). A refused row changes nothing.
  4. The item is added, or its changed fields are copied to the stored item.
  5. A price is added when the row has one and it differs (price or basis) from the price in effect on its effective day, which is the row's EffectiveDate or the import's.

A row counts as added, changed (details, labor or price) or unchanged. The import keeps the first CatalogImporter.MaxProblems (100) problems.

Assemblies​

A CatalogAssembly is a reusable bill of items, stored in the Estimator collection: Code (unique, ignoring case and surrounding spaces), Name, Description, Industry, Category, Unit, Parameters, Components, Variants, LaborMode, LaborUnit, LaborCategory, Status, Version (raised on every save), Notes.

PartHolds
AssemblyParameterName (what formulas read), Label, DefaultValue, Unit
AssemblyComponentEntryKind (Item or Assembly), Code (an item number or an assembly code), QuantityPerUnit or QuantityFormula, Condition, WastePercent, Notes
AssemblyVariantName and the parameter Values it sets
AssemblyLaborModeSumComponents (the labor of the items) or OwnUnit (LaborUnit, which replaces the labor of everything under the assembly)
Index tableOne row perColumns
CatalogAssemblyIndexAssemblyCode, NormalizedCode, Name, Category, Status
CatalogAssemblyComponentIndexItem or assembly an assembly usesAssemblyId, NormalizedCode, for where-used lookups

The CatalogMigrations update to version 2 creates them. The design is recorded in docs/engineering/adr/0005-assemblies.md in the repository.

Formulas​

Formulas and conditions are Orchard Core expressions (ExpressionFormulaEvaluator, an IFormulaEvaluator of the engine). [Quantity] and every parameter are decimal fields, by their exact names; every built-in function works, such as CEILING([Quantity] / 8) or IF([Surface] = 1, 2, 0); aggregates are refused. A true condition is 1 and a false one 0; a condition of 0 leaves the component out. A formula that gives no value (such as a division by zero) or text fails. Compiled formulas are cached per formula and variable set. ExpressionFormulaEvaluator.Validate(formula, names) returns the reason a formula is not valid, which the editor shows on its component.

Rules​

AssemblyRules.Validate checks the code and name; parameter names (a letter, then letters, digits or underscores, unique, not Quantity); that each component has a code and exactly one of a quantity per unit (0 or more) or a valid formula; valid conditions; waste from 0 to 100; own labor hours of 0 or more; that no component is the assembly itself; and that variants have a name and set only parameters of the assembly. The editor also checks that each item and nested assembly exists, and that saving would not make assemblies contain each other (AssemblyCalculator.FindCycleAsync). An assembly that other assemblies use cannot be deleted.

Calculation​

AssemblyCalculator.CalculateAsync(AssemblyCalculationRequest) calculates an assembly the way an estimate will:

  1. It loads the assembly and every nested assembly, level by level, and refuses a cycle.
  2. The engine expands the tree (AssemblyExpander.ExpandTree) with the parameter values: the defaults, a variant, then the values given.
  3. Each item is calculated with LineCalculator from an ItemSnapshot: the price in effect on the pricing date, the component waste or the item's, the package size, and the hours of the Standard labor table in the chosen LaborColumn (falling back to the difficult, then the normal hours).
  4. An item under an assembly with its own labor gets no labor. The top-most such assembly of each path adds its own labor line.

The result has the lines (installed and purchase quantities, material, hours, labor, and whether the price or labor unit is missing), the own labor lines, the items and assemblies that do not exist, and the totals. A failing formula or a cycle is a Problem, not an exception.

To calculate many assemblies and items at once, AssemblyCalculator.LoadAsync(assemblyCodes, itemCodes, pricingDate) loads one CatalogSnapshot (the assemblies and every assembly under them, the items and their prices), and Calculate(snapshot, request) and CalculateItem(snapshot, ...) calculate from it without reading the database again. The request can also give a Variant, labor Factors (a LaborFactorSet: factors of every item and factors of labor categories) and IncludeTrace for the calculation steps of each line. The estimate workspace calculates its lines this way.

Assembly recipe step​

The EstimatorAssemblies recipe step adds assemblies, or replaces the assemblies with the same codes. Each assembly is a CatalogAssembly in JSON; enum values are names.

{
"steps": [
{
"name": "EstimatorAssemblies",
"Assemblies": [
{
"Code": "REC-20A",
"Name": "Duplex receptacle, 20 A",
"Unit": "EA",
"Parameters": [ { "Name": "CableLength", "Label": "Cable per device", "DefaultValue": 25, "Unit": "ft" } ],
"Components": [
{ "Kind": "Item", "Code": "BOX-4SQ", "QuantityPerUnit": 1 },
{ "Kind": "Item", "Code": "MC-12", "QuantityFormula": "[Quantity] * [CableLength]", "WastePercent": 10 }
],
"Variants": [ { "Name": "Long runs", "Values": { "CableLength": 40 } } ],
"LaborMode": "SumComponents"
}
]
}
]
}

An assembly that breaks a rule adds an error to the recipe. The step does not check that the items exist, so a recipe can add assemblies before or after their items.

Labor model​

DocumentOne perHolds
LaborRateTableRate tableName, Description, IsPrevailingWage, IsDefault (one table is the default), Burdens (LaborTableBurden: Name, Kind = PercentOfWage or AmountPerHour, Value), Classifications (LaborClassificationRate: Classification, Wage, WorkersCompPercent, FringePerHour), OvertimeMultiplier
LaborCrewCrewName, Description, Members (LaborCrewMember: Classification, Count)
LaborFactorDefinitionFactor of the libraryName (the choice), Group (the question), Step (JobConditionStep: Site, Schedule, Conditions), Scope (Estimate, Breakdown or Category), Value, LaborCategory, Description, Order
ProjectLaborSettingsProject with job conditionsProjectId, the rate table and crew with their names, OvertimeSharePercent, the Selections (factor by group), the applied Factors as they were, CombinedFactor, CompositeRate, CompletedUtc, CompletedByUserId
Index tableColumns
LaborRateTableIndexName, IsDefault
LaborCrewIndexName
LaborFactorIndexGroup, Step, Order, Name
ProjectLaborSettingsIndexProjectId

The CatalogMigrations update to version 3 creates them. The design is recorded in docs/engineering/adr/0006-labor-model.md in the repository.

Rates​

LaborSetup.LoadedRate(table, classification) is the engine's LaborRateCalculator.LoadedRate: the wage, plus each burden of the table, plus the classification's workers' compensation (a percentage) and fringe (an amount per hour). LaborSetup.CrewRate(table, crew) is the count-weighted average of the loaded rates of the members (CrewRate.Composite), rounded to cents, or the classifications the table does not have. LaborRules.Validate checks rate tables (a name, an overtime multiplier of 1 or more, burdens of 0 or more and percentages up to 100, unique classifications, wages and fringe of 0 or more, workers' compensation from 0 to 100), crews (a name, at least one member, counts above 0) and factors (a name, a group, a value above 0 and at most 10, a category for a category factor, no line scope).

Job conditions​

JobConditions.Groups(library) turns the factor library into questions: each group is a question on the step of its factors, with its factors as choices, in step, then order. JobConditions.Apply(library, selections) gives the chosen factors and their CombinedFactor (the product of the factors that apply to the whole estimate); FactorFor(category) gives the product of the factors of a labor category. JobConditions.BuildSettings adds the composite rate of the crew with the overtime blended in (LaborRateCalculator.WithOvertime), computed from the unrounded crew rate and rounded to cents.

The job conditions wizard (JobConditionsWizard, type EstimatorJobConditions) runs in the admin on the CrestApps Wizard engine, like the New Bid wizard: JobConditionsWizardHandler adds a step for each step that has questions, then the rates step, and keeps the ProjectLaborSettings of the project when the wizard completes, under the completion lock. A user has one pending session per project; a new session starts from the project's earlier answers and the default rate table. Deleting a project deletes its labor settings (LaborProjectEventHandler).

Labor recipe step​

The EstimatorLabor recipe step adds or replaces rate tables and crews by name, and factors by group and name. Enum values are names.

{
"steps": [
{
"name": "EstimatorLabor",
"RateTables": [
{
"Name": "Standard 2026",
"IsDefault": true,
"OvertimeMultiplier": 1.5,
"Burdens": [ { "Name": "Payroll taxes", "Kind": "PercentOfWage", "Value": 7.65 } ],
"Classifications": [ { "Classification": "Journeyman", "Wage": 42.00, "WorkersCompPercent": 6.5, "FringePerHour": 9.00 } ]
}
],
"Crews": [ { "Name": "Trim crew", "Members": [ { "Classification": "Journeyman", "Count": 2 } ] } ],
"Factors": [ { "Group": "Ceiling height", "Name": "15 to 20 ft", "Step": "Site", "Scope": "Estimate", "Value": 1.10, "Order": 22 } ]
}
]
}

Price file import​

The Save catalog items step (SaveCatalogItems, SaveCatalogItemsStep) is a Data Pipelines destination with a rows input. A pipeline such as Read a media file → Save catalog items is a saved import template: it keeps the field mapping, the vendor and the labor table, and a workflow can run it on a schedule.

Setting (SaveCatalogItemsStepSettings)Meaning
MappingThe input field of each catalog field, by CatalogImportFields name. A missing key matches an input field by name (CatalogImportFields.Guess: the name itself or a common alias such as SKU, Part #, UOM, List Price, Per, Mfr, Cat No); an empty value leaves the field out
Source, VendorWhere the prices come from
EffectiveDateThe day the prices are in effect from, as yyyy-MM-dd; empty for the day the run started
LaborTableThe table of the labor columns; Standard by default
CreateMissingWhether unknown items are added

The step runs for the user who published the pipeline. It needs ManageEstimatorCatalog; without ManageEstimatorPrices or ManageEstimatorLaborUnits it does not keep the prices or the labor columns, and logs a warning. It logs the first 10 refused rows and the counts, and adds a delivery that links to the price update report.

The catalog field names are the properties of CatalogImportRow: ItemNumber, Description, Unit, Price, PricePer, EffectiveDate, Manufacturer, CatalogNumber, Upc, Category, Kind, Industry, MasterFormatSection, PackageSize, WastePercent, LaborCategory, LaborNormal, LaborDifficult, LaborVeryDifficult, LaborBasis and Status. Values reach the importer as text: numbers with the invariant culture, dates as yyyy-MM-dd.

To import CSV files from the media library, allow the .csv extension (see Configuration).

Recipe step​

The EstimatorCatalog recipe step imports items the same way. Each item is an object whose properties are catalog field names; numbers can be JSON numbers or text.

{
"steps": [
{
"name": "EstimatorCatalog",
"Source": "Manual",
"Vendor": "Sample data",
"EffectiveDate": "2026-01-01",
"LaborTable": "Standard",
"Items": [
{
"ItemNumber": "EMT-075",
"Description": "3/4\" EMT conduit, 10 ft",
"Unit": "FT",
"PricePer": "C",
"Price": 62.00,
"LaborNormal": 3.8,
"LaborDifficult": 4.75,
"LaborVeryDifficult": 5.7,
"LaborBasis": "C"
}
]
}
]
}

Source is Manual (the default), PriceFile, Vendor or Quote; EffectiveDate defaults to the day the recipe runs. A refused item adds an error to the recipe. The module ships the Estimator sample catalog recipe (EstimatorSampleCatalog): 30 fictional electrical items with prices and labor units, 5 assemblies built from them, two rate tables, three crews and a factor library of eight questions, for trying the workflow. Its brands, prices and hours are invented; they are not a price service or a published labor manual.

Admin routes​

RouteScreen
/Admin/estimator/catalogThe catalog, as an AdminList, with Options.Search, Options.Category and Options.Status
/Admin/estimator/catalog/Create, /Admin/estimator/catalog/Edit/{itemId}Adds or changes an item, with the hours of the Standard labor table and, on create, a first price
/Admin/estimator/catalog/Delete/{itemId} (POST)Deletes an item and its prices
/Admin/estimator/catalog/Prices/{itemId}The price history of an item; a POST adds a price
/Admin/estimator/assembliesThe assemblies, as an AdminList, with Options.Search and Options.Category
/Admin/estimator/assemblies/Create, /Admin/estimator/assemblies/Edit/{itemId}Adds or changes an assembly; parameter and component rows are added in the browser by assembly-editor.js
/Admin/estimator/assemblies/Test/{itemId}The test panel; ?variant= picks a variant, a POST calculates
/Admin/estimator/assemblies/Search?q=&kind=JSON: the first 15 active items or assemblies matching the text, for the component code boxes
/Admin/estimator/assemblies/Delete/{itemId} (POST)Deletes an assembly that no other assembly uses
/Admin/estimator/labor-rates, /Admin/estimator/crews, /Admin/estimator/labor-factorsThe rate tables, crews and labor factors, as AdminLists, each with Create, Edit/{itemId} and Delete/{itemId} (POST)
/Admin/estimator/job-conditions/Start?projectId={id}Starts or resumes the job conditions wizard of a project; its steps are at /Admin/estimator/job-conditions/Step/{sessionId}
/Admin/estimator/price-updatesThe imports
/Admin/estimator/price-updates/Display/{itemId}The price update report of an import, with Options.Show = Increase, Decrease, New or Same

The list rows read the prices in effect from CatalogListContext, which the controller fills once for the page.

Package layout​

ProjectPurpose
src/Core/CrestApps.OrchardCore.Estimator.CoreThe catalog and assembly models, indexes, stores, CatalogRules, CatalogValues, PriceHistory, CatalogImportFields, CatalogImporter, AssemblyRules, ExpressionFormulaEvaluator and AssemblyCalculator
src/Modules/CrestApps.OrchardCore.Estimator.CatalogThe module: manifest, startups, permissions, admin menu, migration, controllers, display drivers, the Data Pipelines step and its editor, the recipe step, the sample recipe and the views