Estimator Proposals Module
The CrestApps.OrchardCore.Estimator.Proposals module writes the proposal of a bid from an estimate revision: the letter, the base bid and the alternates, unit prices, inclusions, exclusions and clarifications from a clause library, and terms, on the company letterhead. The proposal page follows a Liquid template, and downloads as PDF and Excel.
| Feature ID | Name | Category | Dependencies |
|---|---|---|---|
CrestApps.OrchardCore.Estimator.Proposals | Estimator Proposals | Estimator | CrestApps.OrchardCore.Estimator.Estimates, OrchardCore.Liquid |
The Estimator setup recipe enables the feature and adds a starting clause library with the EstimatorProposalClauses step. The design is recorded in docs/engineering/adr/0012-proposals.md in the repository.
Permissions
Proposals follow the access to their project: CanViewAsync to see them and the files, CanManageAsync to add, change, price and delete them and to change their state. The module adds two permissions (ProposalPermissions), which the Administrator role gets:
| Permission | Name | Allows |
|---|---|---|
ManageClauses | ManageEstimatorProposalClauses | Estimator > Proposal clauses |
ManageSettings | ManageEstimatorProposalSettings | Settings > Proposals (group estimator-proposals) |
Data model
| Type | What it holds |
|---|---|
Proposal | ProjectId, EstimateId, the RevisionId and RevisionNumber the prices come from, the Number in the project, Title, RecipientCompany, RecipientContact, RecipientAddress, ProposalDate, ValidDays, Introduction, Scope, BaseBid, Alternates (ProposalAlternate: AlternateId, Code, Description, Kind, Amount, Include), UnitPrices (ProposalUnitPrice), Inclusions, Exclusions, Clarifications, Terms, State, PricedUtc, SentUtc, DecidedUtc |
ProposalClause | A clause of the library: Kind (Inclusion, Exclusion, Clarification), Text, Category, IsDefault, Position |
ProposalSettings | Site settings: the letterhead (CompanyName, LogoUrl, Address, Phone, Email, Website, LicenseNumber, AccentColor, Footer), the signer (SignerName, SignerTitle), the defaults of new proposals (ValidDays, Introduction, Terms) and the Template (null for the shipped template) |
Both documents are in the Estimator collection, with ProposalIndex (ProjectId, EstimateId, Number, State) and ProposalClauseIndex (Kind, Position, IsDefault, Category).
Rules and services
ProposalRules.Normalize trims the texts and drops empty and repeated clauses (ignoring case) and empty unit prices. ProposalRules.Validate refuses a proposal without a title or a recipient, valid for less than 1 or more than 365 days, or with a unit price without a description or below zero, and a clause without a text (ProposalRuleProblem). ProposalRules.Next(state, action) gives the states: a draft is sent (Send); a sent proposal is accepted or declined; any other state goes back to draft (Reopen).
| Service | Lifetime | What it does |
|---|---|---|
IProposalStore, IProposalClauseStore | Scoped | The proposals of a project or an estimate, newest first; the clauses by kind and position |
ProposalService | Scoped | CreateAsync (the next number, the bid's company or the project's client, the default clauses, the settings, the date in the project's time zone, the prices), PriceAsync (from a revision, keeping the alternates left out), SaveAsync (a draft only), ChangeAsync, DeleteByEstimateAsync |
ProposalDocumentBuilder | Scoped | Writes the ProposalDocument: the letterhead, the parties, paragraphs, amounts and dates formatted in the current culture, the alternates shown, and the Labels in the language of the user |
ProposalPageRenderer | Scoped | Renders the page from the template of the settings or the shipped Templates/proposal.liquid, through ILiquidTemplateManager. A template that fails gives the shipped page with the error |
IProposalPdfRenderer (MigraDocProposalPdfRenderer) | Scoped | Writes the PDF with PDFsharp-MigraDoc 6.2.4, the last MIT release, with the Segoe WP fonts PDFsharp ships, so the PDF is the same on every server |
ProposalWorkbookWriter | Static | Writes the Excel workbook with DocumentFormat.OpenXml; amounts are number cells |
The prices come from EstimateRecap.Calculate: BaseBid is the base bid's selling price, and each alternate's Amount is the change it makes to the selling price, as a positive amount with its kind. ProposalCleanupHandler (an IEstimateEventHandler) deletes the proposals of an estimate that EstimateWorkspace.DeleteAsync deletes.
The Liquid template
The template is a full HTML page. It reads the ProposalDocument as Proposal, as dictionaries and lists, so no type needs registering:
<h1>{{ Proposal.Title }} {{ Proposal.Reference }}</h1>
<p>{{ Proposal.Labels.BaseBid }}: {{ Proposal.BaseBidText }}</p>
{% for alternate in Proposal.Alternates %}
<p>{{ alternate.Code }}: {{ alternate.KindText }} {{ alternate.AmountText }}</p>
{% endfor %}
<ul>{% for clause in Proposal.Exclusions %}<li>{{ clause }}</li>{% endfor %}</ul>
The settings check the syntax with ILiquidTemplateManager.Validate before they save. A template equal to the shipped one is not kept, so a later version of the module brings its changes.
Logo
The page shows the logo from its address. The PDF reads the logo only from the media library (IMediaFileStore), when the address starts with the address of the media library; the server never fetches other addresses.
Admin routes
| Route | Action |
|---|---|
Admin/estimator/proposals/Index?projectId= | The proposals of a project (AdminList) |
Admin/estimator/proposals/Create?projectId=&estimateId= | Add a proposal from an estimate |
Admin/estimator/proposals/Edit/{proposalId} | The editor; read-only unless a draft |
Admin/estimator/proposals/Price/{proposalId} | Take the prices of the estimate's current revision |
Admin/estimator/proposals/Change/{proposalId}?change= | Send, Accept, Decline or Reopen |
Admin/estimator/proposals/Delete/{proposalId} | Delete a proposal |
Admin/estimator/proposals/Preview/{proposalId} | The proposal page |
Admin/estimator/proposals/Pdf/{proposalId}, Excel/{proposalId} | Download the PDF or the workbook |
Admin/estimator/proposal-clauses/Index, Create?kind=, Edit/{clauseId}, Delete/{clauseId} | The clause library (AdminList, filtered by kind) |
The recipe step
EstimatorProposalClauses adds the clauses of its Clauses array, or changes the clause of the same kind and text (ignoring case), so a recipe can run twice:
{
"name": "EstimatorProposalClauses",
"Clauses": [
{ "Kind": "Exclusion", "Text": "Utility company fees.", "Category": "Fees", "IsDefault": true, "Position": 1 }
]
}
Extension points
- Replace
IProposalPdfRendererto write the PDF another way, such as by printing the Liquid page with a browser. - Add clauses for an industry with the
EstimatorProposalClausesrecipe step. - The estimate row and the project page get their proposal links from display drivers of
EstimateandProject.
Tests
tests/CrestApps.OrchardCore.Estimator.Tests/Proposals holds the tests of the rules, the service (on real SQLite), the document, the page (rendered with Fluid), the PDF (read back with PdfPig) and the workbook (read back with OpenXml), and the recipe step.