Skip to main content
Technical ManualLooking for step-by-step instructions? In the User Manual: DocumentsSheetsProjectsNew Bid WizardBidsBid CalendarFeatures and Settings (for Administrators)

Estimator Module

The CrestApps.OrchardCore.Estimator module provides the base Estimator feature: projects, their bids with the bid/no-bid checklist, the New Bid wizard and the bid calendar. The other Estimator features will depend on it.

Feature IDNameCategoryDependencies
CrestApps.OrchardCore.EstimatorEstimatorEstimatorCrestApps.OrchardCore.Addresses, CrestApps.OrchardCore.Resources, CrestApps.OrchardCore.Users, CrestApps.OrchardCore.Wizard

The dependencies supply the site address editor and formatter (Addresses), the searchable item selector (Resources), the user picker with its search endpoint and user display names (Users), and the wizard engine and session store that run the New Bid wizard (Wizard).

Permissions​

PermissionName in the adminImplied byGrants
ManageEstimatorProjectsManage all projects and bidsAdd, change and delete every project and bid
ViewEstimatorProjectsView all projects and bidsManageEstimatorProjectsSee every project and bid
ManageOwnEstimatorProjectsManage own projects and bidsManageEstimatorProjectsAdd projects; see and change the projects the user leads (OwnerUserId) or is on the team of (TeamUserIds), and their bids

The Administrator role gets all three by default. ProjectAuthorizationHandler grants ManageEstimatorProjects and ViewEstimatorProjects for one Project resource when the user has ManageOwnEstimatorProjects and leads the project or is on its team. Check access through EstimatorAccess, which every screen uses:

// Every project, or the user's own projects.
var canOpen = await access.CanUseProjectsAsync(User);

// One project, through ProjectAuthorizationHandler.
var canChange = await access.CanManageAsync(User, project);

The admin menu shows Estimator > Projects and Estimator > Bid calendar when CanUseProjectsAsync is true, and Estimator > New bid when CanCreateAsync is true. A menu item needs all of its permissions, so EstimatorAdminMenu checks the two permissions itself instead of declaring them on the items.

Admin routes​

RouteScreen
/Admin/estimator/projectsThe projects list, with search (name, number or client) and a status filter. ?layout=Grid selects the grid layout when the site lets users choose the layout
/Admin/estimator/projects/CreateAdd a project
/Admin/estimator/projects/Display/{itemId}The project page, with its bids
/Admin/estimator/projects/Edit/{itemId}Change a project
/Admin/estimator/projects/Delete/{itemId} (POST)Delete a project and its bids
/Admin/estimator/new-bidThe New Bid wizard: opens the pending session of the user, or starts one
/Admin/estimator/new-bid/Step/{sessionId}?step={key}A step of the wizard (GET), or saves it and moves on (POST)
/Admin/estimator/new-bid/StartOver/{sessionId} (POST)Cancels the pending session and starts a new one
/Admin/estimator/bidsThe bid calendar
/Admin/estimator/bids/Create?projectId={itemId}Add a bid to a project
/Admin/estimator/bids/Edit/{itemId}Change a bid
/Admin/estimator/bids/Delete/{itemId} (POST)Delete a bid

Both lists render through the AdminList shape. ProjectDisplayDriver and BidDisplayDriver place the row shapes for the SummaryAdmin (List) and SummaryAdminGrid (Grid) display types.

Projects and bids​

Project and Bid are CatalogItem documents in the Estimator YesSql collection, stored through IProjectStore and IBidStore (both ICatalog<T>, scoped).

ModelHolds
ProjectNumber, Name, ClientName, Status, SiteAddress (an Address), TimeZoneId, MarketSector, ConstructionType, BuildingArea, OwnerUserId, TeamUserIds, Description
BidProjectId, BidToCompany, the contact, BidType, DueUtc, Status, Decision and its note, ChecklistRatings, BondRequired, PrevailingWage, BidAmount, WinningAmount, Rank, Notes
Index tableOne row perColumns
ProjectIndexProjectNumber, NormalizedNumber (unique numbers), Name, ClientName, Status, OwnerUserId, ModifiedUtc
ProjectMemberIndexLead or team member of a projectProjectId, UserId
BidIndexBidProjectId, BidToCompany, Status, IsOpen, DueUtc

The ProjectMigrations data migration creates these tables.

Store methodWhat it does
IProjectStore.PageAsync(query, page, pageSize)Pages projects by name, filtered by search text, status and an optional set of project IDs
IProjectStore.FindByNumberAsync(number)Finds a project by its number, ignoring case
IProjectStore.GetNumbersStartingWithAsync(prefix)Lists the numbers with a prefix, for ProjectNumberGenerator (P-{year}-{sequence})
IProjectStore.GetMemberProjectIdsAsync(userId)Lists the projects a user leads or is on the team of
IBidStore.ListByProjectAsync(projectId)Lists the bids of a project, by due time
IBidStore.PageUpcomingAsync(fromUtc, page, pageSize, projectIds)Pages the open bids due from a time on, for the bid calendar
IBidStore.DeleteByProjectAsync(projectId)Deletes the bids of a project and returns how many

Bid rules are in BidStatusRules (allowed status changes and the fields a status needs) and BidSchedule (due-time urgency: due soon within 48 hours, overdue after the due time). Due times are stored in UTC and entered and shown in the project's time zone. The bid calendar shows open bids (Draft or InProgress) with a due time no more than 30 days in the past.

Bid/no-bid checklist​

BidChecklist (in the Core project) defines the ten criteria, their keys and weights, and scores the ChecklistRatings of a bid (a dictionary of criterion key to rating, 1 to 5).

ConstantValueMeaning
MinRating, MaxRating1, 5The rating range. Ratings out of range and unknown keys are ignored, and Clean removes them before a bid is saved
MinimumRatedForRecommendation5Fewer rated criteria give a score but the recommendation NotEnoughRatings
BidThreshold70The lowest score that recommends Bid
ReviewThreshold45The lowest score that recommends Review; below it, DoNotBid

The criteria Relationship, WorkFit, Payment and Margin weigh 2; Location, Capacity, Schedule, Competition, ContractRisk and ScopeClarity weigh 1. The score is round(100 * sum(weight * (rating - 1)) / (4 * sum(weight))) over the rated criteria, with halves rounded away from zero:

var result = BidChecklist.Evaluate(bid.ChecklistRatings);

// result.Score (0 to 100, or null when nothing is rated), result.RatedCount, result.Recommendation

The BidChecklistEditor partial view (Views/Shared) renders the checklist for the bid form and the wizard. Its script, bid-checklist.js, is built from Assets/js/bid-checklist.js by the asset pipeline and updates the score while the ratings change. tests/js/bid-checklist-score.test.mjs checks that the script scores like BidChecklist. The recommendation does not set Bid.Decision, which the estimator chooses.

New Bid wizard​

The New Bid wizard runs on the CrestApps Wizard engine (IWizardSessionStore, IWizardEngine, IDisplayManager<WizardFlow>) with its own admin controller, NewBidController, so it uses the Estimator permissions and admin routes. Its wizard type is EstimatorNewBid, with three data-collecting steps:

Step keyDriverSaves
projectNewBidProjectStepDisplayDriverNewBidProjectDetails: name, a unique number, client, time zone, market sector, construction type, building area
bidNewBidBidStepDisplayDriverNewBidBidDetails: bid-to company, contact, bid type, due time in the time zone of the project, bond and prevailing-wage flags
checklistNewBidChecklistStepDisplayDriverNewBidChecklistDetails: checklist ratings, decision and note
  • NewBidWizardHandler.ActivatingAsync adds the steps, turns off the default wizard chrome (WizardConstants.SuppressDefaultChromePropertyKey), and reserves the identifier of the project in the session properties.
  • NewBidWizardHandler.CompletingAsync builds the project (status Bidding, the user as estimate lead) and its first bid (status Draft) with NewBidBuilder, and creates them through IProjectStore and IBidStore. It runs while the engine holds the completion lock, and the records are saved with the completed session. When another project took the number in the meantime, the project gets the next number of the year.
  • No IWizardDefinition is registered for EstimatorNewBid, so the public /Wizard/Start/{wizardType} route returns 404 for it, and the step drivers render only in the admin (AdminAttribute.IsApplied).
  • A user has one pending session at a time: /Admin/estimator/new-bid opens the latest pending EstimatorNewBid session of the user (queried through WizardSessionIndex), and Start over cancels it.

Project documents​

The documents of a bid package are ProjectDocument items (type, addendum number, received date, SHA-256, size, status), and the pages of drawing PDFs are DrawingSheet items (page, sheet number, title, discipline, revision, scale, page kind, IsSuggested). The design is recorded in docs/engineering/adr/0002-project-documents.md in the repository.

Index tableOne row perColumns
ProjectDocumentIndexDocumentProjectId, DocumentType, AddendumNumber, Sha256, Status, ReceivedUtc
DrawingSheetIndexSheetProjectId, ProjectDocumentId, PageNumber, NormalizedSheetNumber

The DocumentMigrations data migration creates them. DrawingSheetIndex names the document column ProjectDocumentId, because YesSql map index tables already have a DocumentId column.

ServiceLifetimeWhat it does
IProjectFileStore (ProjectFileStore)ScopedThe private file store, a FileSystemStore at App_Data/Sites/{tenant}/Estimator/Documents. Each file is kept at {fileId}/{fileName} and goes through FileCreationService first, so a file event handler (an antivirus scanner) can refuse it
ChunkedUploadStoreScopedUnfinished uploads at App_Data/Sites/{tenant}/Estimator/Uploads, one temporary file per user and upload identifier, appended in order
IProjectDocumentStore, IDrawingSheetStoreScopedThe YesSql stores
DocumentIntakeScopedChecks the file type (DocumentRules.AllowedExtensions) and the addendum number, refuses a file the project already has (by SHA-256), keeps the file, and creates the document: Pending when it is split (DocumentRules.NeedsSplit), Stored otherwise
DocumentProcessorScopedSplits a pending document with PdfDrawingReader and DrawingSheetFactory, which suggests the metadata from TitleBlockReader; sets the status to Ready or Failed with a message
DocumentSplitRunnerScopedRuns the processor for one document under a distributed lock (ESTIMATOR_DOCUMENT_SPLIT_{id}) in a child shell scope, so the sheets are committed before the lock is released
ProjectDocumentRemoverScopedDeletes a document with its sheets and file, or all the documents of a project (used when a project is deleted)
ProjectSheetSetScopedThe documents and the superseded sheets of a project, worked out once per request with SheetSetResolver

The upload runs the split in a deferred task after the response (ShellScope.AddDeferredTask). The DocumentProcessingBackgroundTask runs every minute: it splits the documents still pending (after a failure or a restart), and deletes uploads not written to for a day.

Upload protocol​

The documents page posts each chunk with document-upload.js (built from Assets/js/document-upload.js; its helpers are tested in tests/js/document-upload-plan.test.mjs).

RequestFieldsResponse
GET /Admin/estimator/documents/UploadStatus?projectId={id}&uploadId={uploadId}{ "received": bytes }
POST /Admin/estimator/documents/Upload?projectId={id} (form, with the antiforgery token)UploadId (16 to 64 letters or digits), Offset, TotalLength, FileName, DocumentType, AddendumNumber, Title, ReceivedDate, Notes, and the chunk as the only file200 { "received" } for an accepted chunk; 200 { "completed": true, "redirectUrl" } for the last one; 409 { "received" } for a chunk after a gap; 400 { "error" } when the request is refused

The script names an upload after the SHA-256 of the project and the file's name, size and last change, so choosing the same file again resumes it. The first chunk is checked against the document rules before anything is kept. A chunk sent again is accepted without being written twice.

Sheet set​

SheetSetResolver.FindSuperseded groups the sheets of a project by normalized sheet number (DocumentRules.NormalizeSheetNumber drops case, spaces and dashes). In each group, the sheet from the highest addendum number wins (the original drawing set counts as 0), then the latest received; every other sheet in the group is superseded by it. Sheets without a number are never superseded. Nothing is stored, so deleting or renumbering an addendum takes effect at once.

Document routes​

RouteScreen
/Admin/estimator/documents/Index?projectId={id}The documents of a project, with the upload panel
/Admin/estimator/documents/Download/{itemId}Downloads the file (project view access)
/Admin/estimator/documents/Edit/{itemId}Changes the title, type, addendum number, received date and notes
/Admin/estimator/documents/Split/{itemId} (POST)Splits the document into sheets again
/Admin/estimator/documents/Delete/{itemId} (POST)Deletes the document, its sheets and its file
/Admin/estimator/sheets/Index?projectId={id}The sheet index, filtered with Options.Show=current and Options.DocumentId
/Admin/estimator/sheets/Edit/{itemId}Changes the sheet metadata; clears IsSuggested
/Admin/estimator/sheets/Delete/{itemId} (POST)Deletes a sheet

Viewing documents and sheets, and downloading files, needs CanViewAsync on the project; changing them needs CanManageAsync.

Estimate line storage​

Estimate lines are stored in the Estimator YesSql collection. A revision's lines are split into chunk documents (EstimateLineChunk): one or more per breakdown node, with at most 250 lines each. A large estimate is never one huge document, and an edit rewrites only the chunks that hold the changed lines.

Index tableOne row perUsed for
EstimateLineChunkIndexChunkLoading a revision's chunks in order (RevisionId, NodeOrder, ChunkNumber)
EstimateLineIndexLineFinding the chunk of a line, lists, reports and totals without loading documents

EstimateLineIndex stores amounts as integers: MaterialCents, LaborCents, LaborHoursTenThousandths and QuantityMillionths (see IndexedAmount). Integer columns read back exactly on every database, and the database can sum them exactly. SQLite stores a whole decimal number as an integer, which a decimal index column cannot read back.

The feature registers IEstimateLineStore (scoped, implemented by EstimateLineStore):

MethodWhat it does
ReplaceAsync(revisionId, lines)Replaces every line of a revision
LoadAsync(revisionId)Loads every line, in order
LoadByNodesAsync(revisionId, nodeIds)Loads the lines of some breakdown nodes, reading only their chunks
FindAsync(revisionId, lineIds)Loads some lines, reading only the chunks that hold them
UpdateAsync(revisionId, lines)Adds or replaces lines, rewriting only the chunks that hold them. A line whose breakdown node changed moves to its new node
DeleteLinesAsync(revisionId, lineIds)Removes lines and deletes the chunks they leave empty
GetTotalsAsync(revisionId)Totals the line count, material, labor hours and labor with one SQL aggregate over the line index, including the changes of the current session
GetGroupedTotalsAsync(revisionId)The same totals by alternate and cost type, for the recap

Changes are saved when the YesSql session is committed, at the end of the request. The EstimateLineMigrations data migration creates the index tables when the feature is enabled.

The storage design and its benchmarks are recorded in docs/engineering/adr/0001-estimate-line-storage.md in the repository.

Setup recipe​

The module ships the Estimator setup recipe. See Getting Started for the steps it runs.

Package layout​

ProjectPurpose
src/Abstractions/CrestApps.OrchardCore.Estimator.AbstractionsShared constants: the feature IDs (EstimatorConstants.Features), the YesSql collection name and the manifest values (EstimatorManifestConstants)
src/Core/CrestApps.OrchardCore.Estimator.CoreModels, YesSql indexes, stores and rules, such as ProjectStore, BidStatusRules, BidChecklist, NewBidBuilder, DocumentIntake, DocumentProcessor and EstimateLineStore
src/Modules/CrestApps.OrchardCore.EstimatorThe module: Manifest.cs, Startup.cs, permissions, the admin menu, controllers, display drivers, the New Bid wizard handler, views, scripts (Assets/, built into wwwroot/), migrations and the setup recipe
src/Targets/CrestApps.OrchardCore.Estimator.TargetsOne package that references every Estimator module