mar-pod-b2b / module-b2b-quote
Negotiable company quotes and quote workflow for Marpod B2B.
Package info
gitlab.com/mar-pod-b2b/module-b2b-quote
Type:magento2-module
pkg:composer/mar-pod-b2b/module-b2b-quote
Requires
- php: ^8.5
- magento/framework: ^103.0
- magento/module-catalog: ^104.0
- magento/module-customer: ^103.0
- magento/module-graph-ql: ^100.4
- magento/module-quote: ^101.2
- magento/module-sales: ^103.0
- magento/module-store: ^101.1
- magento/module-webapi: ^100.4
- mar-pod-b2b/module-b2b-company: ^1.0
- mar-pod-b2b/module-b2b-core: ^1.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Negotiable company quotes and quote workflow for Marpod B2B.
Status
This release lands the full service-layer engine (declarative schema, concurrency-safe status-transition workflow, cart-to-quote snapshot creation, seller offer creation, accepted- quote-to-cart conversion, customer/seller messaging) plus an Admin UI for managing quotes, an admin REST surface mirroring that UI, and a customer-facing GraphQL workflow (see REST and GraphQL below); a storefront UI and email notifications are not implemented yet (see Limitations).
Implemented foundation
- declarative schema:
marpod_b2b_quote,marpod_b2b_quote_item,marpod_b2b_quote_message,marpod_b2b_quote_history. Items, messages, and history rows are removed with their quote (onDelete="CASCADE"); the creating/authoring customer reference is set toNULLif that customer account is later removed, so the quote record itself survives; QuoteRepositoryInterfacefor quote persistence and lookup. A new quote defaults toQuoteStatusInterface::DRAFTwithversion = 1unless the caller sets them explicitly;QuoteStatusInterface::ALLOWED_TRANSITIONS, the single source of truth for the workflow:draft -> submitted -> in_review -> offered -> accepted -> ordered | | | +-> rejected+-> expired+-> cancelledQuoteWorkflowInterface::transition(): the only supported way to change a quote's status. It validates the target status against the allowed-transition map, applies the change with an optimistic-lockingUPDATE ... WHERE status = ? AND version = ?(a concurrent transition on the same quote makes the second caller's update affect zero rows, which throwsCouldNotSaveExceptioninstead of silently overwriting the first change), appends a row tomarpod_b2b_quote_history, and dispatchesmarpod_b2b_quote_status_changedwithquote_id/from_status/to_statusonly — no full models in the event payload.QuoteManagementInterface::createFromCart(): the entry point into the workflow. Snapshots the currently logged-in customer's active cart into a newdraftquote — onemarpod_b2b_quoterow (company, customer, website, currency) plus onemarpod_b2b_quote_itemrow per visible cart item (SKU, name, qty, native price, and the item's buy-request as a JSONproduct_optionssnapshot, stripped of theuenc/form_keyUI/CSRF noise it carries in a real cart). The snapshot never changes afterward even if the product, its price, or the cart itself changes. RequiresMarpod\B2bCompany\Api\CompanyPermissionInterface::QUOTE_CREATEfor the customer's company membership (checked throughCompanyContext::isAllowed(), the same service Quick Order and Bulk Add to Cart already use) and throwsLocalizedExceptionwhen no customer is logged in, the company does not permit it, or the cart is missing or empty.QuoteManagementInterface::makeOffer(): the seller/admin counterpart. Sets one or more item offered prices, sets the quote'svalid_until, and moves the quote fromin_reviewtooffered— as a single DB transaction, so an invalid item ID or price rolls back everything, including the status transition. Item ownership is checked (an item ID that does not belong to the given quote is rejected) before anything is written.QuoteManagementInterface::convertToCart(): converts anacceptedquote into a new Magento cart for its owning customer. Re-validates everything the plan requires before adding a single item — the quote is stillacceptedand not pastvalid_until, the owning company is still active, the owning customer is still a member of that company, and per item, the product is still enabled and assigned to the quote's website; availability and allowed quantity are enforced by the cart itself when the item is added (a salability failure raises aLocalizedException, never a silently incomplete cart). Each item is priced at the quote's offered price when one was set — applied via the samecustom_price/\Magento\Quote\Model\Quote\Item\Updatermechanism Magento's own admin order-creation screen uses for negotiated prices — or the product's current price otherwise; a stale snapshot price is never charged. This method only produces a cart; it does not change the quote's status or place an order. Moving the quote toQuoteStatusInterface::ORDEREDis a follow-up concern once an order is actually placed from the resulting cart (not yet designed — see Limitations).
QuoteWorkflowInterface::transition(), QuoteManagementInterface::makeOffer(), and
QuoteManagementInterface::convertToCart() do not re-check company/admin permissions themselves
— the caller (a storefront controller or admin action, both landing in a later release) is
expected to authorize the actor before calling them, the same way createFromCart() authorizes
internally because it owns the customer session.
QuoteMessageRepositoryInterfacefor the customer/seller conversation:save()(author is a customer ID for a customer message ornullfor a seller/admin message, withisFromAdmindistinguishing the two independently of authorship) andgetListByQuoteId()(oldest first). Unlike quote item mutations, this is a plain repository rather than aQuoteManagementmethod — posting a message is a single validated write with no multi-step orchestration to justify the extra layer. Not restricted by the quote's status; conversation may continue regardless of where the quote is in its workflow.
Admin UI
Marpod_B2bQuote::manage (under B2B > Quotes) provides:
- a grid of quotes (
marpod_b2b_quote/quote/index), filterable/sortable by status and other columns; - a single detail screen per quote (
marpod_b2b_quote/quote/edit) showing header fields, items (SKU/name/qty/original price/offered price), the customer/seller message thread, a message-post form, and — only while the quote isin_review— an inline "Make an Offer" form with a price input per item, avalid_untildate, and a note, posting toQuoteManagementInterface::makeOffer(); - status-change buttons generated directly from
QuoteStatusInterface::ALLOWED_TRANSITIONSfor the quote's current status, so the Admin UI can never offer a transition the workflow engine itself would reject; each posts toQuoteWorkflowInterface::transition()with the acting admin represented asactorCustomerId = null.
Like the Price Lists admin screen in module-b2b-pricing, the detail screen is a plain
Block/phtml page rather than a ui_component form, by the same deliberate choice documented
there.
REST and GraphQL
Administrative REST routes below /rest/V1/marpod-b2b/quote require the existing
Marpod_B2bQuote::manage ACL resource and mirror the Admin UI's own actions: quote list/get,
makeOffer, a message-post route (QuoteManagementInterface::postAdminMessage() — hardcodes
author_customer_id=null/is_from_admin=true server-side, refactored out of the PostMessage
admin controller so both share one code path and a REST caller can't forge who sent a message),
and a transition route exposing QuoteWorkflowInterface::transition directly — the same
unrestricted-target-status trust model the existing admin Transition controller already uses.
Authenticated customers get the full workflow through GraphQL: marpodB2bQuote/marpodB2bQuotes
queries (a plain customer needs company.quote.view_own to see their own quotes;
company.quote.view_all to see the whole company) and marpodB2bCreateQuoteFromCart/
marpodB2bSubmitQuote/marpodB2bAcceptQuote/marpodB2bDeclineQuote/marpodB2bCancelQuote/
marpodB2bPostQuoteMessage/marpodB2bConvertQuoteToCart mutations. Unlike Purchase Approval,
QuoteWorkflowInterface::transition() and QuoteManagementInterface::convertToCart() do not
check caller identity themselves (see above) — every one of these resolvers adds its own
ownership check (the quote's customer_id must match the caller) before calling the service,
and the transition mutations always pass a hardcoded target status, never one taken from
GraphQL input.
Requirements
- PHP 8.5
- Magento 2.4.9-compatible framework packages
mar-pod-b2b/module-b2b-coreandmar-pod-b2b/module-b2b-company
Installation
Run from the Magento project root:
composer require mar-pod-b2b/module-b2b-quote:@dev
bin/magento module:enable Marpod_B2bQuote
bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:clean
Development
The planned scope, dependency rules, cache boundaries, security requirements, and test strategy
are documented in packages/B2B_LIGHT_PLAN.md in the development Magento project.
Unit tests:
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist packages/module-b2b-quote/Test/Unit
REST/GraphQL API tests (real HTTP against a running instance — routing, authentication, ACL
enforcement, and ownership isolation; see mar-pod-b2b/module-b2b-core's README for the shared
Test/Api base classes these build on):
vendor/bin/phpunit -c dev/tests/api-functional/phpunit_rest.xml \
packages/module-b2b-quote/Test/Api/Rest/QuoteAdminRestTest.php
vendor/bin/phpunit -c dev/tests/api-functional/phpunit_graphql.xml \
packages/module-b2b-quote/Test/Api/GraphQl/QuoteGraphQlTest.php
Limitations
- no storefront UI yet (Admin UI, REST, and GraphQL are all shipped — see above);
- no link from the cart
convertToCart()produces back to the eventual Magento order, and therefore no automatic transition of the quote toQuoteStatusInterface::ORDEREDonce the customer completes checkout; - no email notifications yet;
- no expiration cron (an
offeredquote pastvalid_untilis not automatically moved toexpired) yet.
Uninstallation
Do not remove the package while another installed module requires it. The marpod_b2b_quote,
marpod_b2b_quote_item, marpod_b2b_quote_message, and marpod_b2b_quote_history tables contain
business data and are retained when the module is disabled or its package is removed.