Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3497553b41 | ||
|
|
29e77a973b | ||
|
|
026ab5bc2d | ||
|
|
483c0ce00f | ||
|
|
ce405d20e6 | ||
|
|
0f07751559 | ||
|
|
53d8a5aefe | ||
|
|
380e860386 |
@@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [0.11.1] - 2026-09-01
|
||||
|
||||
### Fixed
|
||||
- `Modules\Core\Catalog\Services\RecommendationService::recommend()` built its result with the base `Illuminate\Support\Collection` (`collect()`) instead of `Illuminate\Database\Eloquent\Collection`, even though every element is a `Product` model. `ProductIndexer::toSearchableArray()` calling `->load(['media', 'variants.prices'])` on that result threw `BadMethodCallException: Method Illuminate\Support\Collection::load does not exist` — silently failing every `MakeSearchable` queue job for a saved product (visible only as `FAIL` in the queue log, with the real exception in `storage/logs/laravel.log`). Fixed by having `RecommendationService` accumulate into a real `Eloquent\Collection` from the start.
|
||||
|
||||
## [0.11.0] - 2026-09-01
|
||||
|
||||
### Added
|
||||
- `Modules\Core\Catalog\Services\RecommendationService` — computes "related products" for a given product as a configurable, ordered chain of strategies (`config('catalog.recommendation_rules')`), not one hardcoded rule. Tops up from each successive rule until the limit (default 4) is reached or every rule is exhausted — e.g. 3 products from a same-category rule plus 1 from a random fallback — deduplicated across rules so the same product is never returned twice. Ships with `Modules\Core\Catalog\Recommendations\SameCategoryRule` (other products sharing the source product's first collection) and `RandomRule` (the universal fallback, placed last in the default chain). A new rule is just a class implementing `Modules\Core\Catalog\Contracts\RecommendationRule`. Documented in `docs/product-recommendations.md`.
|
||||
- `Modules\Core\Catalog\Services\ProductIndexer` embeds the result directly into each product's own Meilisearch document as `recommendations: [{id, name, price, image}, ...]` (`recommendations.id` filterable) — a product detail page renders its "related products" section with zero extra queries, same reasoning as the existing `collections` field. Deliberately embeds an `id` for the view to build a locale-correct URL from, not a resolved `href` — `product.show` is locale-prefixed, so a URL baked in at index time would only be correct for whichever locale happened to be active during that index run.
|
||||
- `Modules\Core\Catalog\Events\ProductSaved`/`ProductDeleted`, dispatched from `Product::saved()`/`Product::deleted()` in `CatalogServiceProvider` (the latter fires for both a soft delete and a force delete, matching Scout's own `unsearchable()` trigger point) — feed `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct`, which reverse-searches Meilisearch for every product currently recommending the changed/deleted one (`recommendations.id = "..."` — there's no Postgres relation for this, a recommendation only exists inside the index) and re-indexes them via Scout's own `->searchable()`. Product creation is deliberately not hooked into this: a new product not yet appearing as a recommendation elsewhere is an accepted staleness window, the same tradeoff already documented for `in_stock`/`price` — see `docs/product-recommendations.md`.
|
||||
- `CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product" --refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, covering a newly-created product not yet appearing as a recommendation and any other drift already accepted between reindexes. `--refresh` also re-syncs filterable/sortable index settings, not just documents.
|
||||
|
||||
## [0.10.1] - 2026-09-01
|
||||
|
||||
### Added
|
||||
- `Modules\Core\Localization\Services\StorefrontLabels::all()` gains three keys found missing from `3dealer`'s actual `storefront.*` translation usage: `shop.price_min`, `shop.price_max`, `shop.reset` (the price-filter sidebar's min/max labels and its reset link). Picked up by `InstallLunarCommand`'s existing per-key upsert — re-running `lunar:install` on an already-installed store adds only these three rows, leaving everything already seeded or admin-edited untouched.
|
||||
|
||||
## [0.10.0] - 2026-08-31
|
||||
|
||||
### Changed
|
||||
|
||||
+3
-2
@@ -2,7 +2,7 @@
|
||||
"name": "boboko/core",
|
||||
"description": "Core module — authentication and shared panel behaviour",
|
||||
"type": "library",
|
||||
"version": "0.10.0",
|
||||
"version": "0.11.1",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\Core\\": "src/"
|
||||
@@ -42,7 +42,8 @@
|
||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||
"Modules\\Core\\Providers\\CartServiceProvider",
|
||||
"Modules\\Core\\Providers\\ReviewServiceProvider",
|
||||
"Modules\\Core\\Providers\\ShippingServiceProvider"
|
||||
"Modules\\Core\\Providers\\ShippingServiceProvider",
|
||||
"Modules\\Core\\Providers\\OrderServiceProvider"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
use Modules\Core\Catalog\Recommendations\RandomRule;
|
||||
use Modules\Core\Catalog\Recommendations\SameCategoryRule;
|
||||
|
||||
return [
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Product recommendation rules
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Tried in order by Modules\Core\Catalog\Services\RecommendationService —
|
||||
| the first rule that returns at least one product wins. The order here IS
|
||||
| the fallback chain: SameCategoryRule first, then RandomRule as a
|
||||
| last-resort so a product page is never left with zero recommendations
|
||||
| (as long as the store has more than one product). A consuming app can
|
||||
| reorder, add, or remove rules freely — nothing about the chain shape is
|
||||
| hardcoded in the service itself.
|
||||
|
|
||||
*/
|
||||
'recommendation_rules' => [
|
||||
SameCategoryRule::class,
|
||||
RandomRule::class,
|
||||
],
|
||||
];
|
||||
@@ -0,0 +1,155 @@
|
||||
# Product Recommendations
|
||||
|
||||
`Modules\Core\Catalog\Services\RecommendationService` computes "related products" for a given
|
||||
product — a same-category pick today, with a random fallback, but built as a configurable chain of
|
||||
strategies rather than one hardcoded rule. `Modules\Core\Catalog\Services\ProductIndexer` embeds
|
||||
the result directly into each product's own Meilisearch document, so a product detail page renders
|
||||
its recommendations with zero extra queries — same reasoning as `collections` (see
|
||||
`docs/product-listing.md`).
|
||||
|
||||
---
|
||||
|
||||
## The rule chain
|
||||
|
||||
```php
|
||||
use Modules\Core\Catalog\Services\RecommendationService;
|
||||
|
||||
$recommendations = app(RecommendationService::class)->recommend($product, limit: 4);
|
||||
// Illuminate\Support\Collection<int, Lunar\Models\Product>
|
||||
```
|
||||
|
||||
`recommend()` walks `config('catalog.recommendation_rules')` in order, **topping up** from each
|
||||
successive rule until `$limit` distinct products are collected or every rule is exhausted — it does
|
||||
not stop at the first rule that returns *something*. If a product's category only has 3 other
|
||||
products, `SameCategoryRule` contributes those 3 and `RandomRule` fills the last slot. A rule is
|
||||
handed the ids already collected (`$exclude`, always including the source product's own id) so it
|
||||
never wastes its own `$limit` budget re-suggesting something already picked, and the same product
|
||||
is never returned twice even if two rules would both suggest it.
|
||||
|
||||
Default chain (`config/catalog.php`):
|
||||
|
||||
```php
|
||||
'recommendation_rules' => [
|
||||
SameCategoryRule::class, // other products sharing $product's first collection
|
||||
RandomRule::class, // universal fallback — always returns something as
|
||||
// long as the store has more than one product
|
||||
],
|
||||
```
|
||||
|
||||
A consuming app publishes and edits this config to reorder, add, or remove rules — nothing about
|
||||
the chain shape is hardcoded in `RecommendationService` itself. A new rule (same tag, best sellers,
|
||||
"frequently bought together", ...) is a class implementing `Modules\Core\Catalog\Contracts\
|
||||
RecommendationRule`, added to the array:
|
||||
|
||||
```php
|
||||
interface RecommendationRule
|
||||
{
|
||||
/**
|
||||
* @param array<int> $exclude ids to never return — the source product's own
|
||||
* id, plus every id an earlier rule in the chain already picked
|
||||
* @return Collection<int, Product> at most $limit products
|
||||
*/
|
||||
public function recommend(Product $product, int $limit, array $exclude): Collection;
|
||||
}
|
||||
```
|
||||
|
||||
Rules query Eloquent directly (`$product->collections->first()->products()`, `Product::query()`),
|
||||
not `Modules\Core\Catalog\Services\ProductService` — see "Why not `ProductService`" below.
|
||||
|
||||
---
|
||||
|
||||
## Why not `ProductService`
|
||||
|
||||
Every other read path in `Modules\Core\Catalog` goes through `ProductService`, which reads
|
||||
Meilisearch and resolves translated fields to whatever locale the *current request* is in (see
|
||||
`docs/product-listing.md`, "Locale resolution"). Recommendation rules deliberately don't use it:
|
||||
they run inside `ProductIndexer::toSearchableArray()`, at **index time** — there is no request, no
|
||||
meaningful "current locale" to resolve against, and Meilisearch itself may be mid-write for the very
|
||||
product being indexed. Rules return raw `Lunar\Models\Product` models instead; `ProductIndexer`
|
||||
resolves what it embeds (`name` via `translateAttribute()`, `price` via the indexer's own
|
||||
`cheapestPrice()`, `image` via its own `mapMedia()`) the same way it already does for the embedded
|
||||
`collections` field — including that field's same accepted index-time-locale tradeoff (a
|
||||
recommendation's embedded `name` reflects whatever locale was active when *that* product was last
|
||||
indexed, not the viewer's current locale).
|
||||
|
||||
---
|
||||
|
||||
## What's embedded, and why not just an id
|
||||
|
||||
`ProductIndexer` embeds full card data per recommendation, not just an id:
|
||||
|
||||
```php
|
||||
$data['recommendations'] = [
|
||||
['id' => 42, 'name' => 'Espresso Cup', 'price' => 12.5, 'image' => 'https://.../thumb.jpg'],
|
||||
// ...
|
||||
];
|
||||
```
|
||||
|
||||
This shape is deliberately exactly what `x-ui.product-card`/`x-product-grid` (3dealer's storefront
|
||||
components) need — `name`, `price`, `image`, and an `id` the view resolves to a URL itself via
|
||||
`route('product.show', ['id' => $rec['id']])`. A resolved `href` is **not** embedded: `product.show`
|
||||
is locale-prefixed (`{locale}/products/{id}`), so a URL baked in at index time would be correct only
|
||||
for whichever locale happened to be active during that index run — wrong for every other locale.
|
||||
Building the URL is left to the view, which knows the current request's locale.
|
||||
|
||||
`recommendations.id` is marked **filterable** — not for the storefront, but for the reverse-lookup
|
||||
reindexing below.
|
||||
|
||||
---
|
||||
|
||||
## Keeping it fresh: `ProductSaved` / `ProductDeleted`
|
||||
|
||||
A recommendation is computed once, at index time, and embedded — it does not update itself when the
|
||||
recommended product later changes name, price, or image, or is deleted. Unlike `Modules\Core\Catalog\
|
||||
Observers\ProductOptionReindexObserver`'s equivalent problem (which product option value is used by),
|
||||
there is no Postgres relation for "which products currently recommend product X" — a recommendation
|
||||
only exists inside Meilisearch. The fix is a reverse Meilisearch filter query, not a database join,
|
||||
wired through a real event → listener pair (`Modules\Core\Providers\CatalogServiceProvider`):
|
||||
|
||||
- `Product::saved()` dispatches `Modules\Core\Catalog\Events\ProductSaved`.
|
||||
- `Product::deleted()` dispatches `Modules\Core\Catalog\Events\ProductDeleted` — fires for both a
|
||||
soft delete and a force delete (`Lunar\Models\Product` uses `SoftDeletes`), the same model event
|
||||
Laravel Scout's own `ModelObserver` hooks to make a deleted product `unsearchable()`.
|
||||
- `Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct` handles both: it searches the
|
||||
product index for `recommendations.id = "{id}"`, finds every referencing product, and calls
|
||||
`->searchable()` on each — which recomputes their `recommendations` field fresh, picking up the
|
||||
changed name/price/image, or (for a delete) dropping the now-gone product and topping back up to
|
||||
the configured limit via the rule chain, same as any other reindex.
|
||||
|
||||
`->searchable()` dispatches Scout's own reindex job, queued if `SCOUT_QUEUE` is configured — this
|
||||
listener does no synchronous Meilisearch writing itself.
|
||||
|
||||
**Product creation is deliberately not hooked into this.** A brand-new product has no
|
||||
`recommendations` of its own until Scout's existing create-triggered indexing runs (already correct
|
||||
— nothing to add). What's *not* immediate is other products picking the new one up as a fresh
|
||||
recommendation candidate — that happens on their own next natural reindex (a save, or the nightly
|
||||
full reindex below), the same accepted staleness window `docs/product-listing.md` already documents
|
||||
for `in_stock`/`price`. A full proactive "who could now recommend this new product" pass was
|
||||
considered and rejected as unnecessary cost for a cosmetic delay.
|
||||
|
||||
---
|
||||
|
||||
## Nightly full reindex
|
||||
|
||||
`Modules\Core\Providers\CatalogServiceProvider` schedules `lunar:search:index "Lunar\Models\Product"
|
||||
--refresh` daily at 03:00 — a safety net on top of the event-driven reindexing above, not a
|
||||
replacement for it. Catches what event-driven reindexing deliberately doesn't cover: a newly-created
|
||||
product not yet appearing as a recommendation elsewhere, and any other drift already accepted
|
||||
between reindexes (see `docs/product-listing.md`, "Stock goes stale between orders"). `--refresh`
|
||||
also re-syncs filterable/sortable index *settings*, not just documents, so a deploy that changed
|
||||
`ProductIndexer`'s field list self-heals overnight even if `lunar:meilisearch:setup` wasn't run
|
||||
manually right after that deploy.
|
||||
|
||||
---
|
||||
|
||||
## Re-syncing after this change
|
||||
|
||||
Same as any other `ProductIndexer` field change (see `docs/product-listing.md`):
|
||||
|
||||
```bash
|
||||
php artisan lunar:meilisearch:setup
|
||||
php artisan lunar:search:index "Lunar\Models\Product" --refresh
|
||||
```
|
||||
|
||||
Restart the queue worker if `SCOUT_QUEUE=true` — see `docs/product-listing.md`'s "Re-syncing after
|
||||
this change" for why a running worker won't otherwise pick up the new indexer code.
|
||||
@@ -0,0 +1,450 @@
|
||||
<title>Order Feature Survey</title>
|
||||
<style>
|
||||
:root {
|
||||
--paper: #FAFAF7;
|
||||
--ink: #1C1C1A;
|
||||
--muted: #6B6B63;
|
||||
--accent: #2F5D50;
|
||||
--accent-soft: #E4EDE9;
|
||||
--good: #3F7A5C;
|
||||
--good-soft: #E6F0EA;
|
||||
--warn: #B8863B;
|
||||
--warn-soft: #F5ECDC;
|
||||
--miss: #A14B3B;
|
||||
--miss-soft: #F5E5E0;
|
||||
--hairline: #E4E2DB;
|
||||
--card: #FFFFFF;
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) {
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--paper: #17181A;
|
||||
--ink: #EDEBE4;
|
||||
--muted: #9B9A90;
|
||||
--accent: #7FBFA8;
|
||||
--accent-soft: #1E2C27;
|
||||
--good: #6FBF97;
|
||||
--good-soft: #1B2A22;
|
||||
--warn: #D9A85C;
|
||||
--warn-soft: #2C2418;
|
||||
--miss: #D97C68;
|
||||
--miss-soft: #2E1E1A;
|
||||
--hairline: #2C2D2E;
|
||||
--card: #1E1F21;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--paper: #17181A;
|
||||
--ink: #EDEBE4;
|
||||
--muted: #9B9A90;
|
||||
--accent: #7FBFA8;
|
||||
--accent-soft: #1E2C27;
|
||||
--good: #6FBF97;
|
||||
--good-soft: #1B2A22;
|
||||
--warn: #D9A85C;
|
||||
--warn-soft: #2C2418;
|
||||
--miss: #D97C68;
|
||||
--miss-soft: #2E1E1A;
|
||||
--hairline: #2C2D2E;
|
||||
--card: #1E1F21;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: "IBM Plex Sans", ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 15.5px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
padding: 4.5rem 1.5rem 6rem;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 780px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
header.page {
|
||||
margin-bottom: 3.25rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-weight: 560;
|
||||
font-size: clamp(2.1rem, 4.5vw, 2.65rem);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
margin: 0 0 0.9rem;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
.dek {
|
||||
color: var(--muted);
|
||||
max-width: 60ch;
|
||||
font-size: 1.02rem;
|
||||
}
|
||||
|
||||
.dek strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
margin-top: 1.6rem;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.28rem 0.6rem;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chip.have { background: var(--good-soft); color: var(--good); }
|
||||
.chip.partial { background: var(--warn-soft); color: var(--warn); }
|
||||
.chip.missing { background: var(--miss-soft); color: var(--miss); }
|
||||
|
||||
section.category {
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.cat-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.85rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding-bottom: 0.7rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
|
||||
.cat-num {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-size: 1.05rem;
|
||||
color: var(--accent);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 1.6rem;
|
||||
}
|
||||
|
||||
.cat-head h2 {
|
||||
font-family: "Fraunces", Georgia, serif;
|
||||
font-weight: 500;
|
||||
font-size: 1.28rem;
|
||||
margin: 0;
|
||||
letter-spacing: -0.005em;
|
||||
}
|
||||
|
||||
.cat-note {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
margin: 0 0 1.2rem;
|
||||
max-width: 62ch;
|
||||
}
|
||||
|
||||
.feature {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.3rem 1rem;
|
||||
padding: 1.05rem 0;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.feature:last-child { border-bottom: none; }
|
||||
|
||||
.f-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.f-status {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.22rem 0.55rem;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.f-status.have { background: var(--good-soft); color: var(--good); }
|
||||
.f-status.partial { background: var(--warn-soft); color: var(--warn); }
|
||||
.f-status.missing { background: var(--miss-soft); color: var(--miss); }
|
||||
|
||||
.f-note {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--muted);
|
||||
font-size: 0.87rem;
|
||||
margin-top: 0.15rem;
|
||||
max-width: 66ch;
|
||||
}
|
||||
|
||||
.f-note code {
|
||||
font-family: "IBM Plex Mono", ui-monospace, monospace;
|
||||
font-size: 0.82em;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
padding: 0.08em 0.35em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
footer.page {
|
||||
margin-top: 4rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
footer.page a { color: var(--accent); }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
body { padding: 3rem 1.1rem 4rem; }
|
||||
.feature { grid-template-columns: 1fr; }
|
||||
.f-status { justify-self: start; }
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400..600&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap">
|
||||
|
||||
<div class="wrap">
|
||||
|
||||
<header class="page">
|
||||
<div class="eyebrow">boboko / order · competitive survey</div>
|
||||
<h1>What order management elsewhere can do that boboko can't yet</h1>
|
||||
<p class="dek">
|
||||
Where the Checkout survey stopped — the instant <code>Order</code> exists — this
|
||||
one starts. A feature-by-feature pass across Shopify, WooCommerce, PrestaShop, and
|
||||
(briefly) Magento's post-placement order layer — sourced, not recalled from memory —
|
||||
checked against what <strong>Lunar's <code>Order</code> model and the
|
||||
already-shipped Filament <code>ManageOrder</code> page</strong> actually support
|
||||
today. For deciding what the new <code>Order</code> module needs to own, not a
|
||||
build order.
|
||||
</p>
|
||||
<div class="legend">
|
||||
<span class="chip have">● have</span>
|
||||
<span class="chip partial">◐ partial</span>
|
||||
<span class="chip missing">○ missing</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">01</span>
|
||||
<h2>Status model</h2>
|
||||
</div>
|
||||
<p class="cat-note">One field, or several axes — and who's allowed to move it.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Payment status independent of a single overall status</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note">The data exists — <code>ManageOrder::paymentStatus()</code> derives a real value from <code>transactions()</code>/<code>captureTotal()</code>/<code>refundTotal()</code>/<code>intentTotal()</code> — but it's a computed display value on the admin page, not a stored column or something the rest of the system (mailers, automations) can key off. Shopify and Magento both make payment status a first-class, independently-queryable dimension; here it's derived on the fly, once, in one Filament page.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Fulfillment status independent of overall status</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No equivalent of <code>paymentStatus()</code> exists for shipment/fulfillment state — <code>Order</code> has no <code>shipments()</code> relation of its own at all; it's added dynamically by <code>Modules\Core\Shipping\Providers\ShippingServiceProvider::resolveRelationUsing()</code>, outside Order's own boundary (see docs/checkout.md, "Where Order would likely absorb work"). Every platform researched (Shopify, Woo, PrestaShop, Magento) treats "has this shipped" as derivable from child records, not a manually-set field — Lunar has the child records (<code>Shipment</code>) but no derived status method reading them.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Staff-editable order status with a picker/action</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code> ships a working <code>UpdateStatusAction</code> out of the box, backed by <code>config('lunar.orders.statuses')</code> — a flat, merchant-configured list, each entry carrying a <code>label</code>/<code>color</code>/<code>favourite</code> flag. Closer to WooCommerce's single linear field than Shopify's multi-axis split.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Status rows carry behavior (auto-send email, generate invoice, restock)</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note">Each status entry in <code>config('lunar.orders.statuses')</code> already declares <code>mailers</code> and <code>notifications</code> arrays — the PrestaShop-style shape is there in config — but per the Checkout survey's finding, nothing in core actually reads and dispatches from those keys on a transition. The data model for "status carries behavior" exists; the behavior doesn't.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Order-status-changed event other code can react to</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Same gap the Checkout survey flagged for order creation: no <code>OrderStatusUpdated</code>/equivalent exists anywhere in core. <code>UpdateStatusAction</code> just writes the column. Anything wanting to react to a status change — a confirmation email, a webhook, re-deriving payment/fulfillment status — has to hook the raw Eloquent <code>Order::updated()</code> event and diff <code>status</code> itself.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">02</span>
|
||||
<h2>Fulfillment & shipment tracking</h2>
|
||||
</div>
|
||||
<p class="cat-note">Turning a placed order into a package that moves.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Shipment as its own record, separate from the order</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Modules\Core\Shipping\Models\Shipment</code> (carrier, tracking reference, label-printed timestamp, manifest reference) already exists and belongs to <code>Order</code>. Built this session, ahead of most gaps in this survey — the record shape is closer to Magento's per-shipment entity than Woo's "no shipment entity at all."</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Multiple shipments per order (partial/split fulfillment)</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note"><code>Shipment</code> has no <code>quantity</code>-per-line or <code>order_line_id</code> concept — it's one shipment record per carrier voucher, with a <code>parent_reference</code> for ACS's own multipart-voucher case (one physical order split into multiple packages by the carrier), not a per-line-item fulfillment split decided by staff. Closer to "multiple packages for one shipment" than Magento's true per-line partial-shipment model.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Create-shipment action from the order admin screen</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Modules\Core\Shipping\Extensions\OrderViewExtension</code> adds a working "Create Shipment" header action to <code>ManageOrder</code>, resolving a <code>CarrierFulfillmentInterface</code> by the order's chosen shipping method and calling <code>createShipment()</code> — genuinely wired, not a stub. Currently lives under <code>Shipping</code>, flagged in docs/checkout.md as conceptually an <code>Order</code> concern.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Tracking number + carrier surfaced on the order itself</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Shipment.tracking_reference</code>/<code>carrier</code> exist and are populated by <code>createShipment()</code>; <code>PollShipmentTrackingJob</code> (scheduled every 30 minutes) keeps <code>ShipmentInfo</code> checkpoints current via <code>CarrierFulfillmentInterface::trackShipment()</code>. Genuinely ahead of PrestaShop's thin <code>order_carrier.tracking_number</code> field — this has a real checkpoint history, not just one string.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">"Shipped"/"delivered" status auto-derived from tracking</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">The tracking checkpoints exist (<code>ShipmentInfo</code>, <code>TrackingStatus</code> enum including <code>Delivered</code>) but nothing writes them back onto <code>Order.status</code> — a delivered shipment doesn't move the order out of whatever status it was already in. Every platform researched treats "delivered" as a status a customer/staff can see on the order, not something buried one relation away.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Shipping/delivery notification emails (shipped, out-for-delivery, delivered)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Research: Shopify fires four separate templated notifications across this window alone (shipping confirmation, out-for-delivery, delivered, plus edited-order). None of the pieces exist here — no order-status-changed event (01) to trigger from, and no mailer wired to <code>PollShipmentTrackingJob</code>'s own status updates either.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">03</span>
|
||||
<h2>Payments: capture, refund, cancellation</h2>
|
||||
</div>
|
||||
<p class="cat-note">Money moving back out, and orders that never should have been placed.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Refund action from the order screen, amount-scoped</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code>'s <code>refund</code> action already exists — picks a transaction, an amount (validated against <code>availableToRefund()</code>), and notes, then calls the driver's own <code>Transaction::refund()</code>. This is genuinely native, matching Woo/Magento's line-item-adjacent (if not line-item-exact) refund UX.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Capture action for auth-then-capture payment flows</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code>'s <code>capture</code> action + <code>requiresCapture()</code>/<code>canBeRefunded()</code> guard methods already exist, delegating to <code>Transaction::capture()</code> — this is the Stripe "authorize now, capture later" flow's admin-side half, already built ahead of most gaps here.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Refund tied to specific line items (not just a dollar amount)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">The refund action takes a transaction + amount, with no line-item selection or restock decision — WooCommerce and Magento both make "which items, how many, restock or not" the primary refund UI; here it's one number against one transaction, closer to a manual adjustment than a structured partial return.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Order cancellation as a distinct action (vs. just changing status)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No dedicated "cancel" action exists on <code>ManageOrder</code> — a cancellation today would just be picking a "cancelled"-labeled entry from the generic status dropdown (01), with no automatic refund trigger, no stock-release logic, and no distinction from any other manual status edit.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Refund/capture reflected back into an order-level payment status</div>
|
||||
<span class="f-status partial">partial</span>
|
||||
<div class="f-note">Same gap as 01's payment-status finding — <code>paymentStatus()</code> recomputes correctly from transactions when the admin page loads, but a refund doesn't push the order into a <code>refunded</code>/<code>partially-refunded</code> overall status the way Shopify's <code>displayFinancialStatus</code> does automatically.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">04</span>
|
||||
<h2>Returns (RMA)</h2>
|
||||
</div>
|
||||
<p class="cat-note">The one area every researched platform treats as optional, not core.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Return-merchandise-authorization flow (customer requests, staff approves)</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No <code>Return</code>/RMA model, status set, or request flow exists anywhere in this codebase. Consistent with the research: Shopify is the only platform of the four with this genuinely native; PrestaShop ships it off-by-default; Magento gates it behind the paid Adobe Commerce tier; WooCommerce lacks it entirely. Safe to treat as a real gap, not an urgent one.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Return shipping label generation</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Depends entirely on the RMA flow above existing first — <code>CarrierFulfillmentInterface</code> already has the label-printing primitive (<code>printLabel()</code>) a return label would reuse, so the carrier-side plumbing isn't the blocker, the RMA request/approval model is.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">05</span>
|
||||
<h2>Order editing</h2>
|
||||
</div>
|
||||
<p class="cat-note">Changing a placed order — and where every platform draws the line.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Editing guardrails keyed to fulfillment state</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">No line-item add/remove exists on a placed order at all today (unlike Shopify/Woo/PrestaShop, which all allow it up to some fulfillment-keyed cutoff, then force a return instead) — so there's no guardrail to speak of yet because there's no editing to guard. Whatever gets built here should key the cutoff to <code>Shipment</code> existing, per the pattern all four researched platforms converge on.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Editable shipping/billing address after placement</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note"><code>OrderAddress</code> rows are snapshotted at creation (see Checkout survey, 02) and nothing in <code>ManageOrder</code> exposes editing them afterward — every platform researched treats address edits as lower-risk than line-item edits and allows them more freely; this codebase currently allows neither.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Tag editing on a placed order</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>ManageOrder</code>'s <code>edit_tags</code> action already works — the one piece of native post-placement editing that exists today, via <code>HasTags</code> on the <code>Order</code> model.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="category">
|
||||
<div class="cat-head">
|
||||
<span class="cat-num">06</span>
|
||||
<h2>Notes & audit trail</h2>
|
||||
</div>
|
||||
<p class="cat-note">The one thing every researched platform treats as non-negotiable.</p>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Append-only change history (who changed what, when)</div>
|
||||
<span class="f-status have">have</span>
|
||||
<div class="f-note"><code>Order</code> already uses Spatie's <code>LogsActivity</code> trait — every save is recorded with a diff, same underlying mechanism already relied on elsewhere in this codebase (staff activity log, translation history). Structurally equivalent to PrestaShop's <code>order_history</code> table, just via a different package.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Internal staff notes, separate from system-generated log entries</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">The activity log above captures field changes automatically, but there's no free-text "leave a note for the next person" field — every platform researched has this as a distinct feed from the automatic history (Woo's Order Notes, Shopify's Timeline comments, Magento's Comments History), usually with a private-vs-customer-visible toggle. Nothing here yet.</div>
|
||||
</div>
|
||||
|
||||
<div class="feature">
|
||||
<div class="f-name">Customer-visible note-to-customer, sent as a message</div>
|
||||
<span class="f-status missing">missing</span>
|
||||
<div class="f-note">Depends on both the internal-notes feature above and a working mailer (01/02) — genuinely blocked on more foundational gaps, not just unbuilt on its own.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="page">
|
||||
<span>Compiled 2026-09-01 — sources cited inline; <code>vendor/lunarphp/lunar</code> and this codebase's own <code>src/</code> reads are marked by file/class name, Shopify/WooCommerce/PrestaShop/Magento claims are marked "Research."</span>
|
||||
<span>boboko-core / docs</span>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Payment of <strong>{{ $amount }}</strong> for your order <strong>{{ $reference }}</strong> has been captured.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Good news — your order <strong>{{ $reference }}</strong> has been delivered.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>A refund of <strong>{{ $amount }}</strong> has been issued for your order <strong>{{ $reference }}</strong>.</p>
|
||||
@@ -0,0 +1,3 @@
|
||||
<p>Hi,</p>
|
||||
|
||||
<p>Your order <strong>{{ $reference }}</strong> is now: <strong>{{ $statusLabel }}</strong></p>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Contracts;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\Product;
|
||||
|
||||
/**
|
||||
* One strategy for producing recommended products for a given product —
|
||||
* e.g. same category, same tag, best sellers, random. Modules\Core\Catalog\
|
||||
* Services\RecommendationService runs rules registered in
|
||||
* config('catalog.recommendation_rules') in order, topping up from each
|
||||
* successive rule until $limit distinct products are collected or every
|
||||
* rule is exhausted (e.g. 3 from SameCategoryRule + 1 from RandomRule) —
|
||||
* nothing here decides that accumulation itself; a store composes its own
|
||||
* chain by ordering rules in config (e.g. [SameCategoryRule::class,
|
||||
* RandomRule::class]).
|
||||
*
|
||||
* Returns raw Product models, not ProductService::list()'s locale-resolved
|
||||
* array output — this runs at index time (ProductIndexer::toSearchableArray()),
|
||||
* where "the current locale" isn't a meaningful concept the way it is for a
|
||||
* storefront request. ProductIndexer resolves translated fields itself via
|
||||
* translateAttribute(), same as it already does for the embedded `collections`
|
||||
* field — same known index-time-locale tradeoff, not a new one.
|
||||
*/
|
||||
interface RecommendationRule
|
||||
{
|
||||
/**
|
||||
* $exclude carries $product's own id plus every id already picked by an
|
||||
* earlier rule this call — RecommendationService never shows the same
|
||||
* product twice even when two rules would both suggest it, and a rule
|
||||
* shouldn't spend its $limit budget re-returning something already
|
||||
* collected.
|
||||
*
|
||||
* @param array<int> $exclude
|
||||
* @return Collection<int, Product> at most $limit products
|
||||
*/
|
||||
public function recommend(Product $product, int $limit, array $exclude): Collection;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Events;
|
||||
|
||||
/**
|
||||
* Dispatched whenever a Product is deleted (see CatalogServiceProvider,
|
||||
* which wires this to the model's own deleted() hook — fires for both a
|
||||
* soft delete and a force delete, same as Laravel Scout's own
|
||||
* ModelObserver::deleted() that triggers unsearchable() for the product
|
||||
* itself). Same purpose as Modules\Core\Catalog\Events\ProductSaved: lets
|
||||
* Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct find
|
||||
* and re-index every OTHER product that currently embeds this one in its
|
||||
* `recommendations` field, so a deleted product doesn't linger as a dead
|
||||
* reference elsewhere. Carries only the id, not the Product model — by the
|
||||
* time this fires the model may already be gone (force delete), and the
|
||||
* reverse lookup only ever needs the id to filter on.
|
||||
*/
|
||||
class ProductDeleted
|
||||
{
|
||||
public function __construct(
|
||||
public readonly int $productId,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Events;
|
||||
|
||||
use Lunar\Models\Product;
|
||||
|
||||
/**
|
||||
* Dispatched whenever a Product is saved (see CatalogServiceProvider,
|
||||
* which wires this to the model's own saved() hook) — exists specifically
|
||||
* so Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct can
|
||||
* find and re-index every OTHER product that currently embeds this one in
|
||||
* its own `recommendations` field (see ProductIndexer). Those products
|
||||
* have no direct database relationship to this one — a recommendation is
|
||||
* computed and stored only inside Meilisearch (Modules\Core\Catalog\
|
||||
* Services\RecommendationService) — so nothing about their own save
|
||||
* lifecycle would otherwise pick up this product's changed name/price/
|
||||
* image.
|
||||
*/
|
||||
class ProductSaved
|
||||
{
|
||||
public function __construct(
|
||||
public readonly Product $product,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Listeners;
|
||||
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\Events\ProductDeleted;
|
||||
use Modules\Core\Catalog\Events\ProductSaved;
|
||||
|
||||
/**
|
||||
* Keeps every product's embedded `recommendations` field (see
|
||||
* ProductIndexer) in sync when a product they recommend changes or is
|
||||
* removed. Unlike Modules\Core\Catalog\Observers\ProductOptionReindexObserver's
|
||||
* equivalent ("who references this option value"), there is no Postgres
|
||||
* table to query here — a recommendation only exists inside Meilisearch,
|
||||
* computed by RecommendationService at index time — so the reverse lookup
|
||||
* is a Meilisearch filter query against `recommendations.id`, not a
|
||||
* database join.
|
||||
*
|
||||
* Handles both ProductSaved (name/price/image changed — referencing
|
||||
* products' embedded copy is stale) and ProductDeleted (the recommended
|
||||
* product no longer exists at all — referencing products need to drop it
|
||||
* and, since RecommendationService tops up to its limit, naturally pick up
|
||||
* a replacement on reindex). Same reverse lookup either way, just a
|
||||
* different source for the id being searched for.
|
||||
*
|
||||
* Re-indexing via ->searchable() dispatches Scout's own (queued, if
|
||||
* SCOUT_QUEUE is configured) reindex job per matched product — this
|
||||
* listener itself does no synchronous Meilisearch writing.
|
||||
*/
|
||||
class ReindexProductsRecommendingProduct
|
||||
{
|
||||
public function handleSaved(ProductSaved $event): void
|
||||
{
|
||||
$this->reindexReferencingProducts($event->product->id);
|
||||
}
|
||||
|
||||
public function handleDeleted(ProductDeleted $event): void
|
||||
{
|
||||
$this->reindexReferencingProducts($event->productId);
|
||||
}
|
||||
|
||||
private function reindexReferencingProducts(int $productId): void
|
||||
{
|
||||
$hits = Product::search('')
|
||||
->options([
|
||||
'filter' => "recommendations.id = \"{$productId}\"",
|
||||
'attributesToRetrieve' => ['id'],
|
||||
// Meilisearch's own hitsPerPage default (20) would silently
|
||||
// drop referencing products past that count — this is a
|
||||
// reverse lookup, not a paginated storefront result, so it
|
||||
// needs every match, up to Meilisearch's hard limit.
|
||||
'hitsPerPage' => 1000,
|
||||
])
|
||||
->raw()['hits'] ?? [];
|
||||
|
||||
$ids = collect($hits)->pluck('id')->unique()->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Product::whereIn('id', $ids)->get()->each->searchable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Recommendations;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\Contracts\RecommendationRule;
|
||||
|
||||
/**
|
||||
* The universal fallback — always returns something as long as the store
|
||||
* has more than one product, since it has no eligibility condition of its
|
||||
* own to come up empty on. Meant to be placed last in
|
||||
* config('catalog.recommendation_rules'), not first: every store using
|
||||
* the default chain gets a real fallback, but one that only kicks in once
|
||||
* more specific rules (same category, same tag, ...) have had a chance.
|
||||
*/
|
||||
class RandomRule implements RecommendationRule
|
||||
{
|
||||
public function recommend(Product $product, int $limit, array $exclude): Collection
|
||||
{
|
||||
return Product::query()
|
||||
->whereKeyNot($exclude)
|
||||
->inRandomOrder()
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Recommendations;
|
||||
|
||||
use Illuminate\Support\Collection;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\Contracts\RecommendationRule;
|
||||
|
||||
/**
|
||||
* Recommends other products sharing at least one of $product's directly-
|
||||
* assigned collections — takes $product's first collection (a product
|
||||
* usually has one primary category; if it has several, the first is as
|
||||
* good a choice as any without a "primary collection" concept to prefer).
|
||||
* Returns nothing if $product has no collection at all, letting the next
|
||||
* rule in the chain (see RecommendationRule's docblock) take over.
|
||||
*
|
||||
* Queries Eloquent directly rather than going through Modules\Core\Catalog\
|
||||
* Services\ProductService — this runs at index time (see
|
||||
* RecommendationRule's docblock), where Meilisearch may be mid-reindex for
|
||||
* this very product and ProductService::list()'s locale-resolution has no
|
||||
* meaningful "current locale" to resolve against anyway.
|
||||
*/
|
||||
class SameCategoryRule implements RecommendationRule
|
||||
{
|
||||
public function recommend(Product $product, int $limit, array $exclude): Collection
|
||||
{
|
||||
$collection = $product->collections->first();
|
||||
|
||||
if ($collection === null) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return $collection->products()
|
||||
->whereKeyNot($exclude)
|
||||
->inRandomOrder()
|
||||
->limit($limit)
|
||||
->get();
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,21 @@ use Spatie\MediaLibrary\MediaCollections\Models\Media;
|
||||
* Reflects stock as of the last reindex only — nothing currently reindexes a
|
||||
* product when an order decrements its stock (see docs/product-listing.md).
|
||||
*
|
||||
* - recommendations (recommendations.id filterable): [{id, name, price, image}, ...]
|
||||
* up to 4 other products to show alongside this one (a "related products"
|
||||
* section), sourced from Modules\Core\Catalog\Services\RecommendationService's
|
||||
* configured rule chain (config('catalog.recommendation_rules')). Embedded
|
||||
* card data, not just ids, same reasoning as `collections`: renders
|
||||
* directly with zero extra Meilisearch calls. `name` is resolved via
|
||||
* translateAttribute() at index time (not through ProductService's
|
||||
* per-request locale resolution, since indexing has no "current locale"
|
||||
* the way a storefront request does) — same known index-time-locale
|
||||
* tradeoff `collections` already has. `recommendations.id` is filterable
|
||||
* specifically so Modules\Core\Catalog\Listeners\
|
||||
* ReindexProductsRecommendingProduct can find every product currently
|
||||
* recommending a given one, when that one changes — there's no Postgres
|
||||
* relation for this, a recommendation only exists inside the index.
|
||||
*
|
||||
* A review is created/edited independently of its product (Modules\Core\Providers\
|
||||
* ReviewServiceProvider re-indexes the product on review create/update/delete), so
|
||||
* this data doesn't go stale between full reindexes.
|
||||
@@ -67,6 +82,7 @@ class ProductIndexer extends BaseProductIndexer
|
||||
'slugs',
|
||||
'channel_ids',
|
||||
'in_stock',
|
||||
'recommendations.id',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -126,6 +142,16 @@ class ProductIndexer extends BaseProductIndexer
|
||||
$data['in_stock'] = $model->variants->contains(
|
||||
fn (ProductVariant $variant) => $variant->canBeFulfilledAtQuantity(1)
|
||||
);
|
||||
$data['recommendations'] = app(RecommendationService::class)
|
||||
->recommend($model)
|
||||
->load(['media', 'variants.prices'])
|
||||
->map(fn (Product $recommendation) => [
|
||||
'id' => $recommendation->id,
|
||||
'name' => $recommendation->translateAttribute('name'),
|
||||
'price' => $this->cheapestPrice($recommendation, $currency),
|
||||
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
|
||||
])
|
||||
->all();
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Catalog\Services;
|
||||
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Lunar\Models\Product;
|
||||
use Modules\Core\Catalog\Contracts\RecommendationRule;
|
||||
|
||||
/**
|
||||
* Runs each rule in config('catalog.recommendation_rules'), in order,
|
||||
* topping up from each successive rule until $limit distinct products are
|
||||
* collected or every rule is exhausted — e.g. 3 from SameCategoryRule
|
||||
* (the product's category only has 3 other products) + 1 from RandomRule.
|
||||
* No rule is special-cased as "the fallback" here; a store gets fallback
|
||||
* behaviour purely by how it orders its own config (e.g. SameCategoryRule
|
||||
* before RandomRule). Never returns the same product twice even if two
|
||||
* rules would both suggest it (see RecommendationRule's $exclude), and
|
||||
* never returns fewer than $limit unless the store genuinely doesn't have
|
||||
* that many other products at all.
|
||||
*/
|
||||
class RecommendationService
|
||||
{
|
||||
/**
|
||||
* @return Collection<int, Product>
|
||||
*/
|
||||
public function recommend(Product $product, int $limit = 4): Collection
|
||||
{
|
||||
$recommendations = new Collection();
|
||||
|
||||
foreach (config('catalog.recommendation_rules', []) as $ruleClass) {
|
||||
if ($recommendations->count() >= $limit) {
|
||||
break;
|
||||
}
|
||||
|
||||
$exclude = [$product->id, ...$recommendations->pluck('id')];
|
||||
$remaining = $limit - $recommendations->count();
|
||||
|
||||
/** @var RecommendationRule $rule */
|
||||
$rule = app($ruleClass);
|
||||
$recommendations = $recommendations->merge(
|
||||
$rule->recommend($product, $remaining, $exclude)
|
||||
);
|
||||
}
|
||||
|
||||
return $recommendations->take($limit)->values();
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,9 @@ class StorefrontLabels
|
||||
'shop.search_label' => ['en' => 'Search products', 'el' => 'Αναζήτηση προϊόντων'],
|
||||
'shop.search_placeholder' => ['en' => 'Search products…', 'el' => 'Αναζήτησε προϊόντα…'],
|
||||
'shop.filter_price' => ['en' => 'Filter by price', 'el' => 'Φίλτρο τιμής'],
|
||||
'shop.price_min' => ['en' => 'Min price', 'el' => 'Ελάχιστη τιμή'],
|
||||
'shop.price_max' => ['en' => 'Max price', 'el' => 'Μέγιστη τιμή'],
|
||||
'shop.reset' => ['en' => 'Reset', 'el' => 'Επαναφορά'],
|
||||
'shop.apply' => ['en' => 'Apply', 'el' => 'Εφαρμογή'],
|
||||
'shop.availability' => ['en' => 'Availability', 'el' => 'Διαθεσιμότητα'],
|
||||
'shop.in_stock_only' => ['en' => 'In-stock products only', 'el' => 'Μόνο διαθέσιμα προϊόντα'],
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Enums;
|
||||
|
||||
/**
|
||||
* Derived from Shipment/ShipmentInfo — no equivalent existed anywhere in
|
||||
* Lunar or this codebase before OrderStatus::fulfillment().
|
||||
*/
|
||||
enum FulfillmentStatus: string
|
||||
{
|
||||
case Unfulfilled = 'unfulfilled';
|
||||
case Shipped = 'shipped';
|
||||
case PartiallyShipped = 'partially-shipped';
|
||||
case Delivered = 'delivered';
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Enums;
|
||||
|
||||
/**
|
||||
* Same states/logic as Lunar's own ManageOrder::paymentStatus(), which
|
||||
* only exists as a Filament-page Livewire #[Computed] method — this is
|
||||
* that same derivation, reusable from anywhere via Order::paymentStatus().
|
||||
*/
|
||||
enum PaymentStatus: string
|
||||
{
|
||||
case Offline = 'offline';
|
||||
case Uncaptured = 'uncaptured';
|
||||
case Captured = 'captured';
|
||||
case PartialRefund = 'partial-refund';
|
||||
case Refunded = 'refunded';
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
|
||||
/**
|
||||
* Dispatched by TransactionObserver::saved() when a Transaction's type
|
||||
* changes to 'capture' (from 'intent') and succeeds. Unlike refunds,
|
||||
* Lunar's Stripe driver (StoreCharges) reuses the same Transaction row
|
||||
* across intent -> capture rather than creating a new one, so this can't
|
||||
* key off `wasRecentlyCreated` the way OrderRefunded does.
|
||||
*/
|
||||
class OrderCaptured
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly Transaction $transaction,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Shipping\Models\ShipmentInfo;
|
||||
|
||||
/**
|
||||
* Dispatched by Order's DeriveOrderDeliveredFromShipment listener, which
|
||||
* reacts to Shipping's ShipmentStatusUpdatedByCarrier — delivery is a
|
||||
* tracking checkpoint, not a manual status write, so it never goes through
|
||||
* OrderStatusUpdated. Order.status itself is left untouched here; this is
|
||||
* only the signal for delivery notifications and similar reactions.
|
||||
*/
|
||||
class OrderDelivered
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ShipmentInfo $shipmentInfo,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
|
||||
/**
|
||||
* Dispatched by TransactionObserver::saved() whenever a successful
|
||||
* type=refund Transaction row is written — every payment driver (Lunar's
|
||||
* own StripePaymentType::refund(), or a future boboko-owned driver for a
|
||||
* provider Lunar doesn't ship) creates a new row for each refund, so
|
||||
* `created` alone (filtered to type+success) is enough here, unlike
|
||||
* captures which can reuse an existing row.
|
||||
*/
|
||||
class OrderRefunded
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly Transaction $transaction,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Lunar\Models\Order;
|
||||
|
||||
/**
|
||||
* Dispatched by OrderObserver::updated() whenever an Order's status column
|
||||
* changes, regardless of what wrote it — Filament's UpdateStatusAction,
|
||||
* artisan tinker, a future API. Lunar's own UpdatesOrderStatus trait fires
|
||||
* mailers inline, but only for that one admin action; this event is the
|
||||
* general-purpose hook everything else (our own mailers, automations,
|
||||
* derived payment/fulfillment status) should listen to instead.
|
||||
*/
|
||||
class OrderStatusUpdated
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
public function __construct(
|
||||
public readonly Order $order,
|
||||
public readonly ?string $previousStatus,
|
||||
public readonly string $newStatus,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Listeners;
|
||||
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
/**
|
||||
* Translates a carrier tracking checkpoint into OrderDelivered — the event
|
||||
* OrderDeliveredNotification (via NotificationRegistry) actually listens
|
||||
* to. Kept separate from the notification itself so the "is this checkpoint
|
||||
* a delivery" filtering doesn't leak into notification code.
|
||||
*/
|
||||
class DeriveOrderDeliveredFromShipment
|
||||
{
|
||||
public function handle(ShipmentStatusUpdatedByCarrier $event): void
|
||||
{
|
||||
if ($event->shipmentInfo->status !== TrackingStatus::Delivered) {
|
||||
return;
|
||||
}
|
||||
|
||||
$order = $event->shipmentInfo->shipment->order;
|
||||
|
||||
if (! $order) {
|
||||
return;
|
||||
}
|
||||
|
||||
OrderDelivered::dispatch($order, $event->shipmentInfo);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderCaptured;
|
||||
|
||||
class OrderCapturedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderCaptured $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.captured.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderCaptured::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Payment captured for your order :reference', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.captured', [
|
||||
'reference' => $order->reference,
|
||||
'amount' => $this->event->transaction->amount->formatted,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderDelivered;
|
||||
|
||||
class OrderDeliveredNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderDelivered $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.delivered.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderDelivered::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference has been delivered', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.delivered', [
|
||||
'reference' => $order->reference,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderRefunded;
|
||||
|
||||
class OrderRefundedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderRefunded $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.refunded.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderRefunded::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('A refund has been issued for your order :reference', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.refunded', [
|
||||
'reference' => $order->reference,
|
||||
'amount' => $this->event->transaction->amount->formatted,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Notifications;
|
||||
|
||||
use Illuminate\Notifications\AnonymousNotifiable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Support\Facades\Notification as NotificationFacade;
|
||||
use Modules\Core\Notification\BaseNotification;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
|
||||
class OrderStatusUpdatedNotification extends BaseNotification
|
||||
{
|
||||
public function __construct(private readonly OrderStatusUpdated $event) {}
|
||||
|
||||
public static function getKey(): string
|
||||
{
|
||||
return 'order.status_updated.customer.mail';
|
||||
}
|
||||
|
||||
public static function listensTo(): string
|
||||
{
|
||||
return OrderStatusUpdated::class;
|
||||
}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail'];
|
||||
}
|
||||
|
||||
public function notifiable(): AnonymousNotifiable
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
$email = $order->billingAddress?->contact_email ?? $order->shippingAddress?->contact_email;
|
||||
|
||||
return NotificationFacade::route('mail', $email);
|
||||
}
|
||||
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
$order = $this->event->order;
|
||||
|
||||
return (new MailMessage)
|
||||
->subject(__('Your order :reference has been updated', ['reference' => $order->reference]))
|
||||
->view('core::order.notifications.status-updated', [
|
||||
'reference' => $order->reference,
|
||||
'statusLabel' => config("lunar.orders.statuses.{$order->status}.label", $order->status),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Observers;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Events\OrderStatusUpdated;
|
||||
|
||||
class OrderObserver
|
||||
{
|
||||
public function updated(Order $order): void
|
||||
{
|
||||
if (! $order->wasChanged('status')) {
|
||||
return;
|
||||
}
|
||||
|
||||
OrderStatusUpdated::dispatch(
|
||||
$order,
|
||||
$order->getOriginal('status'),
|
||||
$order->status,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Observers;
|
||||
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Order\Events\OrderCaptured;
|
||||
use Modules\Core\Order\Events\OrderRefunded;
|
||||
|
||||
class TransactionObserver
|
||||
{
|
||||
public function saved(Transaction $transaction): void
|
||||
{
|
||||
if (! $transaction->success) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($transaction->type === 'refund' && $transaction->wasRecentlyCreated) {
|
||||
OrderRefunded::dispatch($transaction->order, $transaction);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($transaction->type === 'capture' && $transaction->wasChanged('type')) {
|
||||
OrderCaptured::dispatch($transaction->order, $transaction);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Order\Support;
|
||||
|
||||
use Lunar\Models\Order;
|
||||
use Modules\Core\Order\Enums\FulfillmentStatus;
|
||||
use Modules\Core\Order\Enums\PaymentStatus;
|
||||
use Modules\Core\Shipping\Enums\TrackingStatus;
|
||||
|
||||
/**
|
||||
* Payment/fulfillment state, derived on read from transactions and
|
||||
* shipments rather than stored — mirrors the logic Lunar's own
|
||||
* ManageOrder::paymentStatus() computes as a Livewire #[Computed] method
|
||||
* (Filament-page-only, not reusable), reimplemented here as a plain,
|
||||
* queryable value any code can call via Order::macro() in
|
||||
* OrderServiceProvider.
|
||||
*/
|
||||
class OrderStatus
|
||||
{
|
||||
public static function payment(Order $order): PaymentStatus
|
||||
{
|
||||
$transactions = $order->transactions;
|
||||
|
||||
$intentTotal = $transactions
|
||||
->filter(fn ($t) => $t->type === 'intent' && $t->success)
|
||||
->sum('amount.value');
|
||||
|
||||
$captureTotal = $transactions
|
||||
->filter(fn ($t) => $t->type === 'capture' && $t->success)
|
||||
->sum('amount.value');
|
||||
|
||||
$refundTotal = $transactions
|
||||
->filter(fn ($t) => $t->type === 'refund' && $t->success)
|
||||
->sum('amount.value');
|
||||
|
||||
$total = $intentTotal ?: $captureTotal;
|
||||
|
||||
if (! $total) {
|
||||
return PaymentStatus::Offline;
|
||||
}
|
||||
|
||||
if (
|
||||
($refundTotal && $refundTotal < $total) ||
|
||||
($captureTotal && $captureTotal < $intentTotal)
|
||||
) {
|
||||
return PaymentStatus::PartialRefund;
|
||||
}
|
||||
|
||||
if ($refundTotal >= $total) {
|
||||
return PaymentStatus::Refunded;
|
||||
}
|
||||
|
||||
if ($captureTotal >= $intentTotal) {
|
||||
return PaymentStatus::Captured;
|
||||
}
|
||||
|
||||
return PaymentStatus::Uncaptured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads shipments.shipmentInfo if already eager-loaded (the caller's
|
||||
* job — e.g. Order::with('shipments.shipmentInfo')) and picks the
|
||||
* latest checkpoint in PHP, instead of Shipment::latestShipmentInfo()'s
|
||||
* per-shipment query — calling this across a list of orders would
|
||||
* otherwise be an extra query per shipment.
|
||||
*/
|
||||
public static function fulfillment(Order $order): FulfillmentStatus
|
||||
{
|
||||
$shipments = $order->shipments->reject(fn ($shipment) => $shipment->cancelled_at !== null);
|
||||
|
||||
if ($shipments->isEmpty()) {
|
||||
return FulfillmentStatus::Unfulfilled;
|
||||
}
|
||||
|
||||
$latestStatuses = $shipments->map(function ($shipment) {
|
||||
$latest = $shipment->relationLoaded('shipmentInfo')
|
||||
? $shipment->shipmentInfo->sortByDesc('occurred_at')->first()
|
||||
: $shipment->latestShipmentInfo();
|
||||
|
||||
return $latest?->status ?? TrackingStatus::Pending;
|
||||
});
|
||||
|
||||
if ($latestStatuses->every(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
|
||||
return FulfillmentStatus::Delivered;
|
||||
}
|
||||
|
||||
if ($latestStatuses->contains(fn (TrackingStatus $status) => $status === TrackingStatus::Delivered)) {
|
||||
return FulfillmentStatus::PartiallyShipped;
|
||||
}
|
||||
|
||||
return FulfillmentStatus::Shipped;
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,32 @@
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lunar\Models\Product;
|
||||
use Lunar\Models\ProductOption;
|
||||
use Lunar\Models\ProductOptionValue;
|
||||
use Modules\Core\Catalog\Events\ProductDeleted;
|
||||
use Modules\Core\Catalog\Events\ProductSaved;
|
||||
use Modules\Core\Catalog\Listeners\ReindexProductsRecommendingProduct;
|
||||
use Modules\Core\Catalog\Observers\ProductOptionReindexObserver;
|
||||
use Modules\Core\Catalog\OptionTypes\ColorOptionType;
|
||||
use Modules\Core\Catalog\Services\ProductOptionTypeManager;
|
||||
|
||||
class CatalogServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->mergeConfigFrom(__DIR__ . '/../../config/catalog.php', 'catalog');
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../config/catalog.php' => config_path('catalog.php'),
|
||||
], 'core-config');
|
||||
|
||||
ProductOptionTypeManager::get()->register([
|
||||
ColorOptionType::class,
|
||||
]);
|
||||
@@ -24,5 +39,28 @@ class CatalogServiceProvider extends ServiceProvider
|
||||
|
||||
ProductOptionValue::saved(fn (ProductOptionValue $value) => $observer->valueSaved($value));
|
||||
ProductOptionValue::deleted(fn (ProductOptionValue $value) => $observer->valueDeleted($value));
|
||||
|
||||
Product::saved(fn (Product $product) => Event::dispatch(new ProductSaved($product)));
|
||||
Product::deleted(fn (Product $product) => Event::dispatch(new ProductDeleted($product->id)));
|
||||
|
||||
Event::listen(ProductSaved::class, [ReindexProductsRecommendingProduct::class, 'handleSaved']);
|
||||
Event::listen(ProductDeleted::class, [ReindexProductsRecommendingProduct::class, 'handleDeleted']);
|
||||
|
||||
$this->app->booted(function () {
|
||||
// A full nightly reindex, on top of the per-event reindexing
|
||||
// above — catches everything event-driven reindexing
|
||||
// deliberately doesn't cover: a newly-created product not yet
|
||||
// appearing as a recommendation elsewhere, in_stock/price
|
||||
// drifting from an order decrementing stock outside a product
|
||||
// save, and any other staleness ProductIndexer's own docblock
|
||||
// already documents as accepted between reindexes. --refresh
|
||||
// re-syncs filterable/sortable field settings too, not just
|
||||
// documents, so a deploy that changed ProductIndexer's field
|
||||
// list self-heals here even if `lunar:meilisearch:setup`
|
||||
// wasn't run manually after that deploy.
|
||||
$this->app->make(Schedule::class)
|
||||
->command('lunar:search:index', ['Lunar\\Models\\Product', '--refresh'])
|
||||
->dailyAt('03:00');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace Modules\Core\Providers;
|
||||
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Lunar\Models\Order;
|
||||
use Lunar\Models\Transaction;
|
||||
use Modules\Core\Notification\NotificationRegistry;
|
||||
use Modules\Core\Order\Listeners\DeriveOrderDeliveredFromShipment;
|
||||
use Modules\Core\Order\Notifications\OrderCapturedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderDeliveredNotification;
|
||||
use Modules\Core\Order\Notifications\OrderRefundedNotification;
|
||||
use Modules\Core\Order\Notifications\OrderStatusUpdatedNotification;
|
||||
use Modules\Core\Order\Observers\OrderObserver;
|
||||
use Modules\Core\Order\Observers\TransactionObserver;
|
||||
use Modules\Core\Order\Support\OrderStatus;
|
||||
use Modules\Core\Shipping\Events\ShipmentStatusUpdatedByCarrier;
|
||||
|
||||
class OrderServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function boot(): void
|
||||
{
|
||||
Order::observe(OrderObserver::class);
|
||||
Transaction::observe(TransactionObserver::class);
|
||||
|
||||
Order::macro('paymentStatus', fn () => OrderStatus::payment($this));
|
||||
Order::macro('fulfillmentStatus', fn () => OrderStatus::fulfillment($this));
|
||||
|
||||
Event::listen(ShipmentStatusUpdatedByCarrier::class, DeriveOrderDeliveredFromShipment::class);
|
||||
|
||||
NotificationRegistry::get()->register([
|
||||
OrderDeliveredNotification::class,
|
||||
OrderStatusUpdatedNotification::class,
|
||||
OrderRefundedNotification::class,
|
||||
OrderCapturedNotification::class,
|
||||
]);
|
||||
|
||||
// Lets the consuming app override copy/markup without forking core
|
||||
// — published into resources/views/vendor/core/order/notifications,
|
||||
// which loadViewsFrom() (CoreServiceProvider) already resolves
|
||||
// ahead of the package's own views for the `core::` namespace.
|
||||
$this->publishes([
|
||||
__DIR__ . '/../../resources/views/order/notifications' => resource_path('views/vendor/core/order/notifications'),
|
||||
], 'core-views');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user