Skip to content

Split OrderViewModelService into note, address and add-product services - #979

Merged
KrzysztofPajak merged 6 commits into
developfrom
refactor/split-order-viewmodel-service
Oct 23, 2026
Merged

KrzysztofPajak merged 6 commits into
developfrom
refactor/split-order-viewmodel-service

Conversation

@KrzysztofPajak

@KrzysztofPajak KrzysztofPajak commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Part of the architecture backlog (item S5: god classes, OrderViewModelService)
Type: feature

Not a pure refactor: besides the split, this PR changes behavior in the places listed under Fixes. Each fix is in its own commit, so it can be reviewed or reverted apart from the split.

Issue

Grand.Web.AdminShared/Services/OrderViewModelService.cs was 1358 lines with 38 constructor dependencies. Eight of its members (order notes, the address editor and the "add product to order" page) are used only by BaseOrderManagementController (Admin and Store) and brought in eight dependencies nothing else in the class used.

Solution

  • Three services split out, registered as scoped next to IOrderViewModelService, the same lifetime as before:

    Service Members Dependencies
    IOrderNoteViewModelService PrepareOrderNotes, InsertOrderNote, DeleteOrderNote 4
    IOrderAddressViewModelService PrepareOrderAddressModel, UpdateOrderAddress 6
    IOrderProductViewModelService PrepareAddOrderProductModel, PrepareAddProductToOrderModel, AddProductToOrderDetails 15
  • Members moved unchanged (checked by script: every original block, with _field renamed to the constructor parameter, is in the new file).

  • OrderViewModelService keeps the order list and filters, the grid query (PrepareOrderModel, also used by the customer and product order tabs and by the vendor panel), PrepareOrderDetailsModel, PrepareOrders (export) and SaveOrderTags: 968 lines, 6 members, 30 dependencies.

  • The controllers are not split: BaseOrderManagementController takes the three services and the Admin and Store OrderController pass them through, so no route or view changes. The vendor panel (BaseOrderController) is unchanged.

Fixes

Found while reviewing the moved code (separate commits):

  • AddProductToOrderDetails stored the incl. tax unit price as the excl. tax one and the other way round (since Admin Panel - Replacing IFormCollection with strongly typed objects #409). With a non-zero tax the added order item had the wrong unit prices.
  • AddressEdit fell back to a new, unattached address when the address id did not belong to the order; the POST then "saved" it and added an "Address has been edited" note. The address is now taken from the order (billing or shipping, by the billingAddress flag), so only the order's own address can be edited.
  • OrderNoteAdd, ShipmentNoteAdd, MerchandiseReturnNoteAdd, CustomerNoteAdd and VendorNoteAdd accept POST only. Every view already calls them with POST.
  • AddProductToOrderDetails (GET and POST) checks the product against the order's store with IAclService.Authorize and returns to the search otherwise.

Found in the code reviews of this PR (commits 2f7699903, 6ae0ec4ef):

  • Order-note files: OrderNoteAdd took any downloadId from the request, and deleting the note deleted that file. A store manager could point a note at another store's product file and delete it. A note now takes only a file uploaded for its own order (DownloadType.Order with the order id as ReferenceId, which is what the notes form uploads); any other file refuses the note (Result = false with Admin.Orders.OrderNotes.DownloadNotOfOrder, shown by the panel) instead of saving it without the file. Deleting a note deletes only such a file.
  • Product store check: it used the panel's store, which is empty in Main Admin, so Main Admin could add a product another store sells to the order. It now uses order.StoreId, which covers every panel. The product search on that page lists only what the order's store sells (it now gets the order id and checks access to the order), and a refused product returns to the search with Admin.Orders.Products.AddNew.NotInOrderStore instead of silently.
  • Address editor: the fallback described above first threw ArgumentException (a 500 page). An address stored without _id gets a new id on every load, so the id in the link never matched for such orders. The address is now picked by the flag, and an order without that address redirects to its edit page. When validation fails, the form shows what was typed (and keeps BillingAddress) instead of the saved address.
  • Quantity: adding a product with a quantity below 1 is refused with ShoppingCart.QuantityShouldPositive (an existing resource) instead of creating an item with zero or negative totals.
  • Fewer queries: the controller loads the product once and passes it, with the order, to PrepareAddProductToOrderModel and AddProductToOrderDetails (they used to load the product, and the order, again). The notes grid no longer looks up a file for notes that have none.
  • Cleanup: PrepareAddOrderProductModel returns Task.FromResult without async; a redundant OrderId assignment in DeleteOrderNote is gone.

Breaking changes

  • 8 members moved from IOrderViewModelService to the new interfaces. Code outside this repository that calls one of them must inject the matching IOrder<Area>ViewModelService. A class that implements IOrderViewModelService, or subclasses OrderViewModelService and overrides a moved virtual, must move that code to the new service.
  • The constructors of OrderViewModelService, BaseOrderManagementController and the Admin and Store OrderController changed.
  • AddProductToOrderDetails (GET and POST) takes IProductService and IAclService as [FromServices] parameters.
  • IOrderProductViewModelService.PrepareAddProductToOrderModel(Order, Product) and AddProductToOrderDetails(Order, Product, AddProductToOrderModel) take the loaded entities instead of ids; OrderProductViewModelService no longer takes IOrderService and IProductService.
  • The five note-add actions no longer answer GET.
  • IOrderNoteViewModelService.InsertOrderNote returns Task<bool>: false when the file is not one uploaded for the order, and nothing is saved then.
  • The add-product search (POST AddProductToOrder) needs OrderId; the view sends it.
  • Two new resources, in DefaultLanguage.xml and Upgrade/en_250.xml: Admin.Orders.OrderNotes.DownloadNotOfOrder, Admin.Orders.Products.AddNew.NotInOrderStore. No route, permission or setting changes.
  • Attachments of notes created before uploads were tagged with the order (Download.ReferenceId, 2023) are no longer deleted together with their note. Deleting them would need the same check that keeps other stores' files safe, so they stay in the database.

Testing

  1. dotnet build GrandNode.slnx: no errors.
  2. dotnet test: Grand.Web.Admin.Tests 1647 (after the review fixes), Grand.Web.Store.Tests 184, Grand.Web.Vendor.Tests 53 passed. New tests: unit prices of an added order item, address not on the order (GET and POST), product outside the panel's store (GET and POST), POST-only note-add actions; review fixes: note files of another order are neither attached nor deleted, notes without a file skip the lookup, the product is checked against the order's store, a stale address id edits the order's address, a missing address redirects, a failed address validation keeps the typed values, a quantity below 1 is refused, the product search is limited to the order's store and to orders the panel can access, a refused note file returns Result = false with a message.
  3. Fresh install with sample data, driven by Playwright, on the split commit. Orders placed through the storefront (cash on delivery): Label at local plugins #1 store product only, Deactivate Two Factor Auth #2 vendor A + store product, Print feature missing products #3 two vendor A products, Reservation inventory #4 vendor B product, Allow manging bestsellers manually directly on the product #5 by a customer registered in the store (Customer:RegisterCustomersPerStore=true).
  4. The fix commits, including the review fixes (Admin, Store and Vendor build), are covered by the unit tests above; they were not run through the browser.

support added 3 commits October 23, 2026 08:45
OrderViewModelService (1358 lines, 38 constructor dependencies) keeps the
order list, details, export query and tags. The order edit page's notes,
address editor and "add product to order" popup move to their own services:

- IOrderNoteViewModelService: PrepareOrderNotes, InsertOrderNote, DeleteOrderNote
- IOrderAddressViewModelService: PrepareOrderAddressModel, UpdateOrderAddress
- IOrderProductViewModelService: PrepareAddOrderProductModel,
  PrepareAddProductToOrderModel, AddProductToOrderDetails

Members are moved verbatim. OrderViewModelService drops to 968 lines and
30 dependencies. BaseOrderManagementController and the Admin and Store
OrderController constructors take the three new services.

Breaking: IOrderViewModelService lost 8 members, and the constructors of
OrderViewModelService, BaseOrderManagementController and both
OrderController classes changed.
…ting an address the order does not have

- AddProductToOrderDetails stored the incl. tax unit price as excl. tax and the
  other way round (since #409). With a non-zero tax the new order item showed
  the wrong unit prices.
- AddressEdit (GET and POST) used to fall back to a new, unattached address when
  the address id did not belong to the order; the POST then "saved" it and added
  an "Address has been edited" note. Both now throw, as the GET's existing check
  intended.
…n the panel's store

- OrderNoteAdd, ShipmentNoteAdd, MerchandiseReturnNoteAdd, CustomerNoteAdd and
  VendorNoteAdd are marked [HttpPost]. Every view already calls them with POST.
- AddProductToOrderDetails (GET and POST) checks the product against the
  panel's store with IAclService.Authorize, the same rule the product search
  on that page uses, and returns to the search otherwise.
Copilot AI balanced review requested due to automatic review settings October 23, 2026 14:31

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

support added 2 commits October 23, 2026 16:54
…t the order's store

- a note only takes a file uploaded for its own order, and deleting a note deletes only such a file
- the product added to an order is checked against the order's store, so Main Admin is covered too
- the address form edits the order's billing or shipping address instead of throwing on a stale id,
  and a failed validation shows the typed values again
- a quantity below 1 is refused when adding a product to an order
- the product and order are loaded once per add-product request; notes without a file skip the lookup
…se file belongs elsewhere

- the add-product search lists what the order's store sells (and checks access to the order),
  and a refused product shows why instead of returning silently to the list
- a note whose file was not uploaded for the order is not saved; the panel shows why
- drop the BOM added to two files in the previous commit

var value = (result as JsonResult)?.Value;
Assert.IsFalse((bool)value!.GetType().GetProperty("Result")!.GetValue(value)!);
Assert.AreEqual("resource", value.GetType().GetProperty("Message")!.GetValue(value));
@KrzysztofPajak
KrzysztofPajak merged commit 2d51bd1 into develop Oct 23, 2026
9 checks passed
@KrzysztofPajak
KrzysztofPajak deleted the refactor/split-order-viewmodel-service branch October 23, 2026 16:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants