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 ID | Name | Category | Dependencies |
|---|---|---|---|
CrestApps.OrchardCore.Estimator | Estimator | Estimator | CrestApps.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
| Permission | Name in the admin | Implied by | Grants |
|---|---|---|---|
ManageEstimatorProjects | Manage all projects and bids | Add, change and delete every project and bid | |
ViewEstimatorProjects | View all projects and bids | ManageEstimatorProjects | See every project and bid |
ManageOwnEstimatorProjects | Manage own projects and bids | ManageEstimatorProjects | Add 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
| Route | Screen |
|---|---|
/Admin/estimator/projects | The 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/Create | Add 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-bid | The 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/bids | The 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).
| Model | Holds |
|---|---|
Project | Number, Name, ClientName, Status, SiteAddress (an Address), TimeZoneId, MarketSector, ConstructionType, BuildingArea, OwnerUserId, TeamUserIds, Description |
Bid | ProjectId, BidToCompany, the contact, BidType, DueUtc, Status, Decision and its note, ChecklistRatings, BondRequired, PrevailingWage, BidAmount, WinningAmount, Rank, Notes |
| Index table | One row per | Columns |
|---|---|---|
ProjectIndex | Project | Number, NormalizedNumber (unique numbers), Name, ClientName, Status, OwnerUserId, ModifiedUtc |
ProjectMemberIndex | Lead or team member of a project | ProjectId, UserId |
BidIndex | Bid | ProjectId, BidToCompany, Status, IsOpen, DueUtc |
The ProjectMigrations data migration creates these tables.
| Store method | What 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).
| Constant | Value | Meaning |
|---|---|---|
MinRating, MaxRating | 1, 5 | The rating range. Ratings out of range and unknown keys are ignored, and Clean removes them before a bid is saved |
MinimumRatedForRecommendation | 5 | Fewer rated criteria give a score but the recommendation NotEnoughRatings |
BidThreshold | 70 | The lowest score that recommends Bid |
ReviewThreshold | 45 | The 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 key | Driver | Saves |
|---|---|---|
project | NewBidProjectStepDisplayDriver | NewBidProjectDetails: name, a unique number, client, time zone, market sector, construction type, building area |
bid | NewBidBidStepDisplayDriver | NewBidBidDetails: bid-to company, contact, bid type, due time in the time zone of the project, bond and prevailing-wage flags |
checklist | NewBidChecklistStepDisplayDriver | NewBidChecklistDetails: checklist ratings, decision and note |
NewBidWizardHandler.ActivatingAsyncadds the steps, turns off the default wizard chrome (WizardConstants.SuppressDefaultChromePropertyKey), and reserves the identifier of the project in the session properties.NewBidWizardHandler.CompletingAsyncbuilds the project (statusBidding, the user as estimate lead) and its first bid (statusDraft) withNewBidBuilder, and creates them throughIProjectStoreandIBidStore. 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
IWizardDefinitionis registered forEstimatorNewBid, 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-bidopens the latest pendingEstimatorNewBidsession of the user (queried throughWizardSessionIndex), 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 table | One row per | Columns |
|---|---|---|
ProjectDocumentIndex | Document | ProjectId, DocumentType, AddendumNumber, Sha256, Status, ReceivedUtc |
DrawingSheetIndex | Sheet | ProjectId, 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.
| Service | Lifetime | What it does |
|---|---|---|
IProjectFileStore (ProjectFileStore) | Scoped | The 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 |
ChunkedUploadStore | Scoped | Unfinished uploads at App_Data/Sites/{tenant}/Estimator/Uploads, one temporary file per user and upload identifier, appended in order |
IProjectDocumentStore, IDrawingSheetStore | Scoped | The YesSql stores |
DocumentIntake | Scoped | Checks 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 |
DocumentProcessor | Scoped | Splits a pending document with PdfDrawingReader and DrawingSheetFactory, which suggests the metadata from TitleBlockReader; sets the status to Ready or Failed with a message |
DocumentSplitRunner | Scoped | Runs 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 |
ProjectDocumentRemover | Scoped | Deletes a document with its sheets and file, or all the documents of a project (used when a project is deleted) |
ProjectSheetSet | Scoped | The 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).
| Request | Fields | Response |
|---|---|---|
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 file | 200 { "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
| Route | Screen |
|---|---|
/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 table | One row per | Used for |
|---|---|---|
EstimateLineChunkIndex | Chunk | Loading a revision's chunks in order (RevisionId, NodeOrder, ChunkNumber) |
EstimateLineIndex | Line | Finding 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):
| Method | What 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
| Project | Purpose |
|---|---|
src/Abstractions/CrestApps.OrchardCore.Estimator.Abstractions | Shared constants: the feature IDs (EstimatorConstants.Features), the YesSql collection name and the manifest values (EstimatorManifestConstants) |
src/Core/CrestApps.OrchardCore.Estimator.Core | Models, YesSql indexes, stores and rules, such as ProjectStore, BidStatusRules, BidChecklist, NewBidBuilder, DocumentIntake, DocumentProcessor and EstimateLineStore |
src/Modules/CrestApps.OrchardCore.Estimator | The 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.Targets | One package that references every Estimator module |