star-cloud/app/Services/Product/ProductCatalogService.php
sky121113 1685b858f9 [FEAT] 每機台商品定價 WIP 暫存
1. 新增 MachineProductPrice 模型與 machine_product_prices 資料表 migration
2. 機台設定頁新增定價分頁 pricing.blade.php
3. 調整 ProductCatalogService、MachineController、Machine 模型支援每機台價格
4. 補貨單修正前的進度暫存,待後續完成

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:38:14 +08:00

192 lines
7.0 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Product;
use App\Models\Machine\Machine;
use App\Models\Machine\MachineProductPrice;
use App\Models\Product\Product;
use App\Models\System\Company;
use Illuminate\Support\Facades\Cache;
class ProductCatalogService
{
/**
* Get the Redis cache key for a company's product catalog.
*
* @param int|null $companyId
* @return string
*/
public function getCacheKey(?int $companyId): string
{
return "products_catalog:" . ($companyId ?? 'global');
}
/**
* Get the product catalog payload, using cache if available.
*
* @param int|null $companyId
* @return array
*/
public function getPayload(?int $companyId): array
{
$cacheKey = $this->getCacheKey($companyId);
$cached = Cache::get($cacheKey);
if ($cached) {
return $cached;
}
return $this->rebuildCache($companyId);
}
/**
* Rebuild the product catalog cache for a company.
*
* @param int|null $companyId
* @return array
*/
public function rebuildCache(?int $companyId): array
{
// 公司機台語系聯集:商品 i18n map 下發的語系集合(見 product_multilingual_spec §5.1
$locales = Company::activeLocalesFor($companyId);
$products = Product::where('company_id', $companyId)
->with(['translations', 'specTranslations'])
->active()
->get()
->map(fn($p) => $this->mapProduct($p, $locales));
$payload = [
'success' => true,
'code' => 200,
'data' => $products->toArray(),
];
Cache::forever($this->getCacheKey($companyId), $payload);
return $payload;
}
/**
* Invalidate the product catalog cache for a company.
*
* @param int|null $companyId
* @return void
*/
public function invalidate(?int $companyId): void
{
Cache::forget($this->getCacheKey($companyId));
}
/**
* 取得「該機台」的商品目錄:公司基底目錄(快取) + 機台專屬定價覆蓋。
*
* 設計取捨:重的 i18n 目錄留在 per-company 快取;機台覆蓋於請求當下即時套用,
* 不另設機台快取。覆蓋只是一次 (machine_id) 的索引查詢,極輕量;且改價僅推「該台」
* 重抓,不會造成多台群湧。如此完全避開「機台快取失效 / 公司基底變動連帶失效」的複雜度。
*
* @param Machine $machine
* @return array
*/
public function getMachinePayload(Machine $machine): array
{
$payload = $this->getPayload($machine->company_id);
$overrides = MachineProductPrice::where('machine_id', $machine->id)
->get(['product_id', 'price', 'member_price'])
->keyBy('product_id');
if ($overrides->isEmpty()) {
return $payload;
}
// PHP 陣列為值複製array_map 產生新陣列,不會污染 Cache 內的公司基底目錄。
$payload['data'] = array_map(function ($item) use ($overrides) {
$override = $overrides->get((int) ($item['t060v00'] ?? 0));
if (!$override) {
return $item;
}
// 機台售價t063v03=App machinePrice有覆蓋價就用否則維持全域售價基底。
$sellingPrice = $override->price !== null
? (float) $override->price
: (float) $item['t063v03'];
$item['t063v03'] = $sellingPrice;
// 機台會員價t060v30有覆蓋就用否則沿用全域會員價
// 一律夾為「不高於機台售價」,避免會員買得比一般人貴。
$memberPrice = $override->member_price !== null
? (float) $override->member_price
: (float) $item['t060v30'];
$item['t060v30'] = min($memberPrice, $sellingPrice);
return $item;
}, $payload['data']);
return $payload;
}
/**
* Map a Product model to the catalog format expected by machines.
*
* 相容鐵則既有欄位t060v01 / t060v01_en / t060v01_jp / t060v03 …)一律保留,
* 僅「新增」locale-keyed 的 t060v01_i18n名稱與 t060v03_i18n規格
* 線上舊 App 忽略新欄位、照舊運作;新版 App 讀 *_i18n 以支援多語系切換。
*
* @param Product $product
* @param array<int,string> $locales 公司機台語系聯集i18n map 下發的語系集合)
* @return array
*/
private function mapProduct($product, array $locales = ['zh_TW']): array
{
$nameEn = $product->translations->firstWhere('locale', 'en')?->value ?? '';
$nameJp = $product->translations->firstWhere('locale', 'ja')?->value ?? '';
return [
't060v00' => (string) $product->id,
't060v01' => $product->name,
't060v01_en' => $nameEn,
't060v01_jp' => $nameJp,
't060v01_i18n' => $this->buildI18nMap($product->translations, $product->name, $locales),
't060v03' => $product->spec ?? '',
't060v03_i18n' => $this->buildI18nMap($product->specTranslations, $product->spec ?? '', $locales),
't060v06' => $product->image_url
? (str_starts_with($product->image_url, 'http') ? $product->image_url : asset($product->image_url))
: '',
't060v09' => (float) $product->price,
't060v11' => (int) ($product->track_limit ?? 10),
't060v30' => (float) ($product->member_price ?? $product->price),
't060v40' => $product->metadata['marketing_plan'] ?? '',
't060v41' => $product->metadata['material_code'] ?? $product->barcode ?? '',
'spring_limit' => (int) ($product->spring_limit ?? 10),
'track_limit' => (int) ($product->track_limit ?? 10),
't063v03' => (float) $product->price,
];
}
/**
* 依公司機台語系聯集,建立 locale → 文字 的 i18n map。
*
* - 僅輸出 $locales 內的語系。
* - 缺翻譯時回退至 zh_TW再回退至 $fallbackTextproducts.name / spec 主值),
* 確保不下發空字串,避免機台顯示空白。
*
* @param \Illuminate\Support\Collection $translations 該欄位的翻譯集合
* @param string|null $fallbackText 最終回退文字(主欄位值)
* @param array<int,string> $locales 公司機台語系聯集
* @return array<string,string>
*/
private function buildI18nMap($translations, ?string $fallbackText, array $locales): array
{
$zhFallback = $translations->firstWhere('locale', 'zh_TW')?->value
?? ($fallbackText ?? '');
$map = [];
foreach ($locales as $locale) {
$map[$locale] = $translations->firstWhere('locale', $locale)?->value ?: $zhFallback;
}
return $map;
}
}