merchant($request); $filters = $this->filters($request); $page = max((int) $request->integer('page', 1), 1); $summaryStats = Cache::remember( CacheKeys::merchantProductsSummary($merchant->id, $filters), now()->addMinutes(10), fn () => $this->buildSummaryStats($this->applyFilters(Product::query()->forMerchant($merchant->id), $filters)) ); $importHistoryData = $this->buildImportHistorySummaryData($merchant->id); return view('merchant_admin.products.index', [ 'merchant' => $merchant, 'products' => Cache::remember( CacheKeys::merchantProductsList($merchant->id, $page, $filters), now()->addMinutes(10), fn () => $this->applySorting($this->applyFilters(Product::query()->forMerchant($merchant->id)->with('category'), $filters), $filters) ->paginate(10) ->withQueryString() ), 'statusStats' => Cache::remember( CacheKeys::merchantProductsStatusStats($merchant->id, array_merge($filters, ['status' => ''])), now()->addMinutes(10), fn () => $this->buildStatusStats($this->applyFilters(Product::query()->forMerchant($merchant->id), array_merge($filters, ['status' => '']))) ), 'summaryStats' => $summaryStats, 'operationsFocus' => $this->buildOperationsFocus($summaryStats, $filters, $merchant->id), 'workbenchLinks' => $this->workbenchLinks($merchant->id), 'filters' => $filters, 'filterOptions' => [ 'statuses' => $this->statusOptions, 'sortOptions' => [ 'latest' => '最新创建', 'price_asc' => '价格从低到高', 'price_desc' => '价格从高到低', 'stock_asc' => '库存从低到高', 'stock_desc' => '库存从高到低', ], ], 'cacheMeta' => [ 'store' => config('cache.default'), 'ttl' => '10m', ], 'activeFilterSummary' => $this->buildActiveFilterSummary($filters), 'statusLabels' => $this->statusLabels(), 'categories' => ProductCategory::query()->where('merchant_id', $merchant->id)->orderBy('sort')->orderBy('id')->get(), 'pageMeta' => [ 'current' => $page, 'perPage' => 10, ], 'importHistoryStats' => $importHistoryData['stats'], 'importHistories' => $importHistoryData['histories'], ]); } public function store(Request $request): RedirectResponse { $merchantId = $this->merchantId($request); $data = $request->validate([ 'category_id' => ['nullable', 'integer'], 'title' => ['required', 'string', 'max:200'], 'slug' => ['required', 'string', 'max:200'], 'sku' => ['nullable', 'string', 'max:120'], 'summary' => ['nullable', 'string', 'max:500'], 'price' => ['required', 'numeric', 'min:0'], 'stock' => ['required', 'integer', 'min:0'], ]); $categoryIdRaw = $data['category_id'] ?? null; $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; if ($categoryId !== null) { $category = ProductCategory::query()->where('merchant_id', $merchantId)->whereKey($categoryId)->first(); if (! $category) { return redirect('/merchant-admin/products')->withErrors(['category_id' => '所选分类不存在或不属于当前商家。']); } } Product::query()->create([ 'merchant_id' => $merchantId, 'category_id' => $categoryId, 'title' => $data['title'], 'slug' => $data['slug'], 'sku' => $data['sku'] ?? '', 'summary' => $data['summary'] ?? '', 'content' => '', 'price' => (float) $data['price'], 'original_price' => (float) $data['price'], 'stock' => (int) $data['stock'], 'status' => 'draft', 'images' => [], ]); $this->flushMerchantCaches($merchantId); return redirect('/merchant-admin/products')->with('success', '商品创建成功'); } public function update(Request $request, int $id): RedirectResponse { $merchantId = $this->merchantId($request); $data = $request->validate([ 'title' => ['required', 'string', 'max:200'], 'slug' => ['required', 'string', 'max:200'], 'sku' => ['nullable', 'string', 'max:120'], 'summary' => ['nullable', 'string', 'max:500'], 'price' => ['required', 'numeric', 'min:0'], 'stock' => ['required', 'integer', 'min:0'], 'status' => ['required', 'string', Rule::in($this->statusOptions)], 'category_id' => ['nullable', 'integer'], ]); $product = Product::query()->forMerchant($merchantId)->findOrFail($id); $categoryIdRaw = $data['category_id'] ?? null; $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; if ($categoryId !== null) { $category = ProductCategory::query()->where('merchant_id', $merchantId)->whereKey($categoryId)->first(); if (! $category) { return redirect('/merchant-admin/products')->withErrors(['category_id' => '所选分类不存在或不属于当前商家。']); } } $product->update([ 'title' => $data['title'], 'slug' => $data['slug'], 'sku' => $data['sku'] ?? '', 'summary' => $data['summary'] ?? '', 'price' => (float) $data['price'], 'stock' => (int) $data['stock'], 'status' => $data['status'], 'category_id' => $categoryId, ]); $this->flushMerchantCaches($merchantId); return redirect('/merchant-admin/products')->with('success', '商品更新成功'); } public function destroy(Request $request, int $id): RedirectResponse { $merchantId = $this->merchantId($request); Product::query()->forMerchant($merchantId)->whereKey($id)->delete(); $this->flushMerchantCaches($merchantId); return redirect('/merchant-admin/products')->with('success', '商品已删除'); } public function export(Request $request): StreamedResponse { $merchantId = $this->merchantId($request); $filters = $this->filters($request); $fileName = 'merchant_' . $merchantId . '_products_' . now()->format('Ymd_His') . '.csv'; $exportSummary = $this->buildSummaryStats( $this->applyFilters(Product::query()->forMerchant($merchantId), $filters) ); return response()->streamDownload(function () use ($merchantId, $filters, $exportSummary) { $handle = fopen('php://output', 'w'); fwrite($handle, "\xEF\xBB\xBF"); fputcsv($handle, ['导出信息', '商家商品导出']); fputcsv($handle, ['导出时间', now()->format('Y-m-d H:i:s')]); fputcsv($handle, ['状态', $this->statusLabel($filters['status'] ?? '')]); fputcsv($handle, ['分类', $this->categoryLabel((string) ($filters['category_id'] ?? ''), $merchantId)]); fputcsv($handle, ['关键词', $this->displayTextValue((string) ($filters['keyword'] ?? ''))]); fputcsv($handle, ['最低价格', $this->displayMoneyValue((string) ($filters['min_price'] ?? ''))]); fputcsv($handle, ['最高价格', $this->displayMoneyValue((string) ($filters['max_price'] ?? ''))]); fputcsv($handle, ['最低库存', $this->displayStockValue((string) ($filters['min_stock'] ?? ''))]); fputcsv($handle, ['最高库存', $this->displayStockValue((string) ($filters['max_stock'] ?? ''))]); fputcsv($handle, ['排序', $this->sortLabel((string) ($filters['sort'] ?? 'latest'))]); fputcsv($handle, ['导出商品数', $exportSummary['total_products'] ?? 0]); fputcsv($handle, ['导出总库存', $exportSummary['total_stock'] ?? 0]); fputcsv($handle, ['导出总货值', number_format((float) ($exportSummary['total_stock_value'] ?? 0), 2, '.', '')]); fputcsv($handle, ['导出平均售价', number_format((float) ($exportSummary['average_price'] ?? 0), 2, '.', '')]); fputcsv($handle, []); fputcsv($handle, [ 'ID', '分类ID', '分类名称', '分类标识', '商品标题', '商品Slug', 'SKU', '售价', '划线价', '库存', '状态', '商品简介', '创建时间', '更新时间', ]); foreach ($this->applySorting($this->applyFilters(Product::query()->forMerchant($merchantId)->with('category'), $filters), $filters)->cursor() as $product) { fputcsv($handle, [ $product->id, $product->category_id, $product->category?->name ?? '', $product->category?->slug ?? '', $product->title, $product->slug, $product->sku, number_format((float) $product->price, 2, '.', ''), number_format((float) $product->original_price, 2, '.', ''), $product->stock, $this->statusLabel((string) $product->status), $product->summary, optional($product->created_at)?->format('Y-m-d H:i:s'), optional($product->updated_at)?->format('Y-m-d H:i:s'), ]); } fclose($handle); }, $fileName, [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function downloadImportTemplate(Request $request): StreamedResponse { $merchantId = $this->merchantId($request); $fileName = 'merchant_' . $merchantId . '_product_import_template.csv'; $header = $this->importTemplateHeader(); return response()->streamDownload(function () use ($header) { $handle = fopen('php://output', 'w'); fwrite($handle, "\xEF\xBB\xBF"); fputcsv($handle, $header); fputcsv($handle, [ '演示商品', 'demo-product', 'SKU-DEMO-001', '199.00', '299.00', 100, 'default', '演示商品简介', 'published', ]); fclose($handle); }, $fileName, [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function import(Request $request): RedirectResponse { $merchantId = $this->merchantId($request); $file = $request->file('import_file') ?: $request->file('csv_file'); if (! $file) { return redirect('/merchant-admin/products')->withErrors(['import_file' => '请上传 CSV 文件。']); } if (! $file instanceof \Illuminate\Http\UploadedFile || $file->getError() !== UPLOAD_ERR_OK) { return redirect('/merchant-admin/products')->withErrors(['import_file' => '上传失败,请重试。']); } $handle = fopen($file->getRealPath(), 'r'); $headerRead = false; $expectedHeader = $this->importTemplateHeader(); $importedCount = 0; $failedCount = 0; $failures = []; while (($row = fgetcsv($handle)) !== false) { if (! $headerRead) { $headerRead = true; if ($row !== $expectedHeader) { fclose($handle); return redirect('/merchant-admin/products')->withErrors(['import_file' => '上传的 CSV 头部与模板不匹配,请重新下载模板。']); } continue; } $data = $this->parseCsvRow($row, $expectedHeader); try { $this->importProductRow([ 'title' => $data['title'] ?? '', 'slug' => $data['slug'] ?? '', 'sku' => $data['sku'] ?? '', 'price' => $data['price'] ?? 0, 'original_price' => $data['original_price'] ?? 0, 'stock' => $data['stock'] ?? 0, 'category_slug' => $data['category_slug'] ?? '', 'summary' => $data['summary'] ?? '', 'status' => $data['status'] ?? 'published', ], $merchantId, count($failures) + 2); $importedCount++; } catch (\InvalidArgumentException $exception) { $failedCount++; $failures[] = $this->buildImportFailureRow(count($failures) + 2, $data, $exception->getMessage()); } } fclose($handle); if ($importedCount > 0) { $this->flushMerchantCaches($merchantId); } $failureFile = $failedCount > 0 ? $this->storeImportFailuresCsv('merchant_' . $merchantId, $expectedHeader, $failures) : null; ProductImportHistory::query()->create([ 'scope' => 'merchant', 'merchant_id' => $merchantId, 'admin_id' => $this->merchantAdmin($request)->id, 'file_name' => $file->getClientOriginalName() ?: $file->getFilename(), 'success_count' => $importedCount, 'failed_count' => $failedCount, 'failure_file' => $failureFile, 'imported_at' => now(), ]); $result = [ 'success' => $importedCount, 'failed' => $failedCount, 'failure_file' => $failureFile, 'messages' => array_slice(array_map(fn (array $failure) => $failure['error'], $failures), 0, 10), ]; return redirect('/merchant-admin/products') ->with('import_result', $result) ->with( $failedCount > 0 ? 'warning' : 'success', $failedCount > 0 ? "商品批量导入完成,成功 {$importedCount} 条,失败 {$failedCount} 条。" : "商品批量导入完成,本次成功导入 {$importedCount} 条。" ); } public function downloadImportFailures(Request $request, string $file): StreamedResponse { $merchantId = $this->merchantId($request); // 商家端限制:只能下载当前商家的 failure 文件(通过 history 表校验) $safeName = basename($file); $history = ProductImportHistory::query() ->where('scope', 'merchant') ->where('merchant_id', $merchantId) ->where('failure_file', $safeName) ->first(); abort_unless($history, 404); $path = 'private/imports/product-failures/' . $safeName; abort_unless(Storage::disk('local')->exists($path), 404); return Storage::disk('local')->download($path, $safeName, [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } public function batchUpdate(Request $request): RedirectResponse { $merchantId = $this->merchantId($request); $returnUrl = $this->sanitizeBatchReturnUrl($request->input('return_url'), '/merchant-admin/products'); $data = $request->validate([ 'product_ids' => ['required', 'array', 'min:1'], 'product_ids.*' => ['integer', 'distinct'], 'action' => ['required', 'string', Rule::in(['change_status', 'change_category'])], 'status' => ['nullable', 'string', Rule::in($this->statusOptions)], 'category_id' => ['nullable', 'integer'], ], [ 'product_ids.required' => '请先选择至少一个商品。', 'product_ids.array' => '批量商品参数格式不正确。', 'product_ids.min' => '请先选择至少一个商品。', 'action.required' => '请选择批量操作类型。', 'action.in' => '暂不支持该批量操作。', 'status.in' => '批量状态仅支持 draft / published / offline。', ]); $productIds = collect($data['product_ids'] ?? [])->map(fn ($id) => (int) $id)->filter(fn ($id) => $id > 0)->values(); if ($productIds->isEmpty()) { return redirect('/merchant-admin/products')->withErrors(['product_ids' => '请先选择至少一个商品。']); } $products = Product::query()->forMerchant($merchantId)->whereIn('id', $productIds)->get(); if ($products->count() !== $productIds->count()) { return redirect('/merchant-admin/products')->withErrors(['product_ids' => '勾选商品中存在越权或已删除的数据,请刷新后重试。']); } $updatedCount = 0; if (($data['action'] ?? '') === 'change_status') { $status = trim((string) ($data['status'] ?? '')); if ($status === '') { return redirect($returnUrl)->withErrors(['status' => '请选择要批量更新的商品状态。']); } $updatedCount = Product::query() ->forMerchant($merchantId) ->whereIn('id', $productIds) ->where('status', '!=', $status) ->update([ 'status' => $status, 'updated_at' => now(), ]); } if (($data['action'] ?? '') === 'change_category') { $categoryIdRaw = $data['category_id'] ?? null; $categoryId = ($categoryIdRaw === null || $categoryIdRaw === '') ? null : (int) $categoryIdRaw; if ($categoryId === null) { $updatedCount = Product::query() ->forMerchant($merchantId) ->whereIn('id', $productIds) ->whereNotNull('category_id') ->update([ 'category_id' => null, 'updated_at' => now(), ]); } else { $category = ProductCategory::query()->where('merchant_id', $merchantId)->where('id', $categoryId)->first(); if (! $category) { return redirect($returnUrl)->withErrors(['category_id' => '所选分类不存在或不属于当前商家。']); } $updatedCount = Product::query() ->forMerchant($merchantId) ->whereIn('id', $productIds) ->update([ 'category_id' => $categoryId, 'updated_at' => now(), ]); } } if ($updatedCount > 0) { $this->flushMerchantCaches($merchantId); } return redirect($returnUrl)->with('success', "批量操作已完成,本次更新 {$updatedCount} 条商品。"); } public function importHistories(Request $request): View { $merchantId = $this->merchantId($request); $filters = $this->importHistoryFilters($request); $query = ProductImportHistory::query() ->where('scope', 'merchant') ->where('merchant_id', $merchantId) ->with(['admin']); if (($filters['result_status'] ?? 'all') === 'success_only') { $query->where('failed_count', 0); } elseif (($filters['result_status'] ?? 'all') === 'has_failures') { $query->where('failed_count', '>', 0); } $start = $filters['start_date'] ?? null; $end = $filters['end_date'] ?? null; if ($start) { $query->whereDate('imported_at', '>=', $start); } if ($end) { $query->whereDate('imported_at', '<=', $end); } $sort = $filters['sort'] ?? 'latest'; if ($sort === 'oldest') { $query->orderBy('imported_at')->orderBy('id'); } else { $query->orderByDesc('imported_at')->orderByDesc('id'); } $importHistories = $query->paginate(15)->withQueryString(); $this->hydrateFailureAvailability($importHistories->getCollection()); $statsQuery = (clone $query)->getQuery(); $stats = ProductImportHistory::query()->fromSub($statsQuery, 'h') ->selectRaw('COUNT(*) as total_imports') ->selectRaw('COALESCE(SUM(success_count), 0) as total_success') ->selectRaw('COALESCE(SUM(failed_count), 0) as total_failed') ->selectRaw('SUM(CASE WHEN failed_count > 0 THEN 1 ELSE 0 END) as warning_imports') ->first(); return view('merchant_admin.products.import_histories', [ 'importHistories' => $importHistories, 'importHistoryFilters' => $filters, 'importHistoryFilterOptions' => [ 'sorts' => [ 'latest' => '最新导入', 'oldest' => '最早导入', ], ], 'importHistoryStats' => [ 'total_imports' => (int) ($stats->total_imports ?? 0), 'total_success' => (int) ($stats->total_success ?? 0), 'total_failed' => (int) ($stats->total_failed ?? 0), 'warning_imports' => (int) ($stats->warning_imports ?? 0), ], ]); } public function exportImportHistories(Request $request): StreamedResponse { $merchantId = $this->merchantId($request); $filters = $this->importHistoryFilters($request); $query = ProductImportHistory::query() ->where('scope', 'merchant') ->where('merchant_id', $merchantId) ->with(['admin']); if (($filters['result_status'] ?? 'all') === 'success_only') { $query->where('failed_count', 0); } elseif (($filters['result_status'] ?? 'all') === 'has_failures') { $query->where('failed_count', '>', 0); } $start = $filters['start_date'] ?? null; $end = $filters['end_date'] ?? null; if ($start) { $query->whereDate('imported_at', '>=', $start); } if ($end) { $query->whereDate('imported_at', '<=', $end); } $sort = $filters['sort'] ?? 'latest'; if ($sort === 'oldest') { $query->orderBy('imported_at')->orderBy('id'); } else { $query->orderByDesc('imported_at')->orderByDesc('id'); } $fileName = 'merchant_' . $merchantId . '_product_import_histories_' . now()->format('Ymd_His') . '.csv'; return response()->streamDownload(function () use ($query, $filters) { $handle = fopen('php://output', 'w'); fwrite($handle, "\xEF\xBB\xBF"); fputcsv($handle, ['导出信息', '商家商品导入历史']); fputcsv($handle, ['导出时间', now()->format('Y-m-d H:i:s')]); fputcsv($handle, ['导入结果', $filters['result_status'] ?? 'all']); fputcsv($handle, []); fputcsv($handle, ['ID', '导入时间', '上传文件', '成功', '失败', '操作者', 'failure_file']); foreach ($query->cursor() as $history) { fputcsv($handle, [ $history->id, optional($history->imported_at)?->format('Y-m-d H:i:s'), $history->file_name, $history->success_count, $history->failed_count, $history->admin?->name ?? '-', $history->failure_file ?? '', ]); } fclose($handle); }, $fileName, [ 'Content-Type' => 'text/csv; charset=UTF-8', ]); } protected function sanitizeBatchReturnUrl(?string $returnUrl, string $default): string { $candidate = trim((string) $returnUrl); if ($candidate === '') { return $default; } return str_starts_with($candidate, '/merchant-admin/products') ? $candidate : $default; } protected function filters(Request $request): array { return [ 'status' => trim((string) $request->string('status')), 'category_id' => trim((string) $request->string('category_id')), 'keyword' => trim((string) $request->string('keyword')), 'min_price' => trim((string) $request->string('min_price')), 'max_price' => trim((string) $request->string('max_price')), 'min_stock' => trim((string) $request->string('min_stock')), 'max_stock' => trim((string) $request->string('max_stock')), 'sort' => trim((string) $request->string('sort', 'latest')), ]; } protected function applyFilters(Builder $query, array $filters): Builder { $categoryId = (int) ($filters['category_id'] ?? 0); return $query ->when(($filters['status'] ?? '') !== '', fn (Builder $builder) => $builder->where('status', $filters['status'])) ->when($categoryId > 0, fn (Builder $builder) => $builder->where('category_id', $categoryId)) ->when(($filters['keyword'] ?? '') !== '', function (Builder $builder) use ($filters) { $keyword = (string) ($filters['keyword'] ?? ''); $builder->where(function (Builder $subQuery) use ($keyword) { $subQuery->where('title', 'like', '%' . $keyword . '%') ->orWhere('sku', 'like', '%' . $keyword . '%') ->orWhere('slug', 'like', '%' . $keyword . '%'); }); }) ->when(($filters['min_price'] ?? '') !== '' && is_numeric($filters['min_price']), fn (Builder $builder) => $builder->where('price', '>=', (float) $filters['min_price'])) ->when(($filters['max_price'] ?? '') !== '' && is_numeric($filters['max_price']), fn (Builder $builder) => $builder->where('price', '<=', (float) $filters['max_price'])) ->when(($filters['min_stock'] ?? '') !== '' && filter_var($filters['min_stock'], FILTER_VALIDATE_INT) !== false, fn (Builder $builder) => $builder->where('stock', '>=', (int) $filters['min_stock'])) ->when(($filters['max_stock'] ?? '') !== '' && filter_var($filters['max_stock'], FILTER_VALIDATE_INT) !== false, fn (Builder $builder) => $builder->where('stock', '<=', (int) $filters['max_stock'])); } protected function applySorting(Builder $query, array $filters): Builder { return match ($filters['sort'] ?? 'latest') { 'price_asc' => $query->orderBy('price')->orderByDesc('id'), 'price_desc' => $query->orderByDesc('price')->orderByDesc('id'), 'stock_asc' => $query->orderBy('stock')->orderByDesc('id'), 'stock_desc' => $query->orderByDesc('stock')->orderByDesc('id'), default => $query->latest(), }; } protected function buildSummaryStats(Builder $query): array { $summary = (clone $query) ->selectRaw('COUNT(*) as total_products') ->selectRaw('COALESCE(SUM(stock), 0) as total_stock') ->selectRaw('COALESCE(SUM(price * stock), 0) as total_stock_value') ->selectRaw('COALESCE(AVG(price), 0) as average_price') ->first(); return [ 'total_products' => (int) ($summary->total_products ?? 0), 'total_stock' => (int) ($summary->total_stock ?? 0), 'total_stock_value' => (float) ($summary->total_stock_value ?? 0), 'average_price' => (float) ($summary->average_price ?? 0), ]; } protected function buildStatusStats(Builder $query): array { $counts = (clone $query) ->selectRaw('status, COUNT(*) as aggregate') ->groupBy('status') ->pluck('aggregate', 'status'); $stats = ['all' => (int) $counts->sum()]; foreach ($this->statusOptions as $status) { $stats[$status] = (int) ($counts[$status] ?? 0); } return $stats; } protected function buildActiveFilterSummary(array $filters): array { return [ '分类' => $this->categoryLabel((string) ($filters['category_id'] ?? ''), null), '状态' => $this->statusLabel((string) ($filters['status'] ?? '')), '关键词' => $this->displayTextValue((string) ($filters['keyword'] ?? '')), '价格区间' => $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')), '库存区间' => $this->formatStockRange((string) ($filters['min_stock'] ?? ''), (string) ($filters['max_stock'] ?? '')), '排序' => $this->sortLabel((string) ($filters['sort'] ?? 'latest')), ]; } protected function statusLabels(): array { return [ 'draft' => '草稿', 'published' => '已上架', 'offline' => '已下架', ]; } protected function statusLabel(string $status): string { if ($status === '') { return '全部'; } return $this->statusLabels()[$status] ?? $status; } protected function categoryLabel(string $categoryId, ?int $merchantId): string { $categoryId = trim($categoryId); if ($categoryId === '' || ! ctype_digit($categoryId) || (int) $categoryId <= 0) { return '全部'; } $query = ProductCategory::query()->whereKey((int) $categoryId); if ($merchantId) { $query->where('merchant_id', $merchantId); } $category = $query->first(); return $category?->name ?? ('分类 #' . $categoryId); } protected function formatMoneyRange(string $min, string $max): string { if ($min === '' && $max === '') { return '全部'; } $minLabel = $min !== '' && is_numeric($min) ? ('¥' . number_format((float) $min, 2, '.', '')) : '不限'; $maxLabel = $max !== '' && is_numeric($max) ? ('¥' . number_format((float) $max, 2, '.', '')) : '不限'; return $minLabel . ' ~ ' . $maxLabel; } protected function formatStockRange(string $min, string $max): string { if ($min === '' && $max === '') { return '全部'; } $minLabel = $min !== '' ? $min : '不限'; $maxLabel = $max !== '' ? $max : '不限'; return $minLabel . ' ~ ' . $maxLabel . ' 件'; } protected function sortLabel(string $sort): string { return match ($sort) { 'price_asc' => '价格从低到高', 'price_desc' => '价格从高到低', 'stock_asc' => '库存从低到高', 'stock_desc' => '库存从高到低', default => '最新创建', }; } protected function displayTextValue(string $value, string $default = '全部'): string { return trim($value) === '' ? $default : $value; } protected function displayMoneyValue(string $value): string { $value = trim($value); if ($value === '') { return '全部'; } return is_numeric($value) ? ('¥' . number_format((float) $value, 2, '.', '')) : $value; } protected function displayStockValue(string $value): string { $value = trim($value); return $value === '' ? '全部' : ($value . ' 件'); } protected function workbenchLinks(int $merchantId): array { return [ 'published_stock_desc' => '/merchant-admin/products?sort=stock_desc&status=published', 'published_stock_asc' => '/merchant-admin/products?sort=stock_asc&status=published', 'latest' => '/merchant-admin/products?sort=latest', 'draft' => '/merchant-admin/products?status=draft&sort=latest', 'current' => '/merchant-admin/products', ]; } protected function buildOperationsFocus(array $summaryStats, array $filters, int $merchantId): array { $publishedCount = (int) Product::query()->forMerchant($merchantId)->where('status', 'published')->count(); $lowStockCount = (int) Product::query()->forMerchant($merchantId)->where('status', 'published')->where('stock', '<=', 20)->count(); $categoryCount = (int) ProductCategory::query()->where('merchant_id', $merchantId)->count(); $links = $this->workbenchLinks($merchantId); $currentQuery = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '' && $value !== 'latest')); $currentUrl = $links['current'] . ($currentQuery !== '' ? ('?' . $currentQuery) : ''); $workbench = [ '高库存已上架' => $links['published_stock_desc'], '低库存补货' => $links['published_stock_asc'], '最近新增' => $links['latest'], '草稿待整理' => $links['draft'], '返回当前筛选视图' => $currentUrl, ]; $signals = [ '已上架商品' => $publishedCount, '低库存商品' => $lowStockCount, '分类覆盖数' => $categoryCount, ]; $response = function (string $headline, array $actions) use ($workbench, $signals) { return [ 'headline' => $headline, 'actions' => $actions, 'workbench' => $workbench, 'signals' => $signals, ]; }; $isPublished = ($filters['status'] ?? '') === 'published'; $hasCategoryFilter = ($filters['category_id'] ?? '') !== ''; $hasKeywordFilter = ($filters['keyword'] ?? '') !== ''; $hasPriceRangeFilter = (($filters['min_price'] ?? '') !== '') || (($filters['max_price'] ?? '') !== ''); $categoryLabel = $hasCategoryFilter ? $this->categoryLabel((string) ($filters['category_id'] ?? ''), $merchantId) : ''; $keyword = (string) ($filters['keyword'] ?? ''); $priceRange = $hasPriceRangeFilter ? $this->formatMoneyRange((string) ($filters['min_price'] ?? ''), (string) ($filters['max_price'] ?? '')) : ''; $hasPublishedStockFocus = $isPublished && ((($filters['max_stock'] ?? '') !== '') || ((($filters['min_stock'] ?? '') !== '') && is_numeric($filters['min_stock']) && (int) $filters['min_stock'] <= 20)); if (($filters['status'] ?? '') === 'draft') { return $response( '当前正在查看草稿商品,建议优先补齐标题、分类、价格与库存后再安排上架。', [ ['label' => '继续查看当前草稿', 'url' => $currentUrl], ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], ] ); } if ($hasPublishedStockFocus) { return $response( '当前筛选已聚焦已上架库存视角,建议优先确认低库存补货节奏,并同步观察高库存结构是否健康。', [ ['label' => '继续查看当前库存视角', 'url' => $currentUrl], ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], ] ); } if ($isPublished && $hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { return $response( '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对在售商品命名、分类承接、价格梯度与搜索结果是否一致,并同步观察库存结构与转化表现是否健康。', [ ['label' => '继续查看当前已上架分类关键词价格带商品', 'url' => $currentUrl], ['label' => '去看当前已上架分类关键词商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], ] ); } if ($isPublished && $hasCategoryFilter && $hasKeywordFilter) { return $response( '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对在售商品命名、分类承接与搜索结果是否一致,并同步观察价格带与库存结构是否健康。', [ ['label' => '继续查看当前已上架分类关键词商品', 'url' => $currentUrl], ['label' => '去看当前已上架分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['keyword' => '']))], ] ); } if ($isPublished && $hasCategoryFilter && $hasPriceRangeFilter) { return $response( '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类在售商品的价格结构、库存分布与转化表现是否协调。', [ ['label' => '继续查看当前已上架分类价格带商品', 'url' => $currentUrl], ['label' => '继续查看当前已上架分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], ] ); } if ($isPublished && $hasKeywordFilter && $hasPriceRangeFilter) { return $response( '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 结果,建议优先核对在售商品命名、卖点表达与价格梯度是否一致,并同步观察库存结构、转化表现与搜索承接是否健康。', [ ['label' => '继续查看当前已上架关键词价格带商品', 'url' => $currentUrl], ['label' => '去看当前已上架关键词商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], ] ); } if ($isPublished && $hasKeywordFilter) { return $response( '当前筛选已聚焦已上架商品中关键词“' . $keyword . '”命中的结果,建议优先核对在售商品命名、卖点表达与搜索承接是否一致,并同步观察价格带与库存结构是否健康。', [ ['label' => '继续查看当前已上架关键词商品', 'url' => $currentUrl], ['label' => '去看全部已上架商品', 'url' => $links['published_stock_desc']], ] ); } if ($hasCategoryFilter && $hasKeywordFilter && $hasPriceRangeFilter) { return $response( '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的价格带 ' . $priceRange . ' 商品,建议优先核对分类承接、命名卖点与价格梯度是否一致,并同步观察库存结构、转化表现与搜索承接是否健康。', [ ['label' => '继续查看当前分类关键词价格带商品', 'url' => $currentUrl], ['label' => '去看当前分类关键词商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], ] ); } if ($hasCategoryFilter && $hasKeywordFilter) { return $response( '当前筛选已聚焦“' . $categoryLabel . '”分类下关键词“' . $keyword . '”命中的商品,建议优先核对分类承接、命名卖点与搜索结果是否一致,并同步观察相关商品的价格带与库存结构。', [ ['label' => '继续查看当前分类关键词商品', 'url' => $currentUrl], ['label' => '继续查看当前分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['keyword' => '']))], ] ); } if ($isPublished && $hasCategoryFilter) { return $response( '当前筛选已聚焦已上架的“' . $categoryLabel . '”分类商品,建议优先核对该分类在售商品的价格带、库存结构与转化表现是否均衡。', [ ['label' => '继续查看当前已上架分类商品', 'url' => $currentUrl], ['label' => '继续查看当前分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['status' => '', 'keyword' => '']))], ] ); } if ($hasCategoryFilter && $hasPriceRangeFilter) { return $response( '当前筛选已聚焦“' . $categoryLabel . '”分类下价格带 ' . $priceRange . ' 商品,建议优先核对该分类价格结构是否连贯,并同步观察库存分布与转化表现是否健康。', [ ['label' => '继续查看当前分类价格带商品', 'url' => $currentUrl], ['label' => '继续查看当前分类商品', 'url' => $this->buildUrl('/merchant-admin/products', array_merge($filters, ['min_price' => '', 'max_price' => '']))], ] ); } if ($hasCategoryFilter) { return $response( '当前筛选已聚焦“' . $categoryLabel . '”分类商品,建议优先核对分类承接是否准确,并同步观察价格带与库存结构是否均衡。', [ ['label' => '继续查看当前分类商品', 'url' => $currentUrl], ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], ] ); } if ($hasKeywordFilter) { return $response( '当前筛选已聚焦关键词“' . $keyword . '”命中的商品,建议优先核对命名、卖点与搜索承接是否一致,并同步观察相关商品的价格带与库存结构。', [ ['label' => '继续查看当前关键词商品', 'url' => $currentUrl], ['label' => '去看最近新增商品', 'url' => $links['latest']], ] ); } if ($hasPriceRangeFilter) { return $response( '当前筛选已聚焦价格带 ' . $priceRange . ' 商品,建议优先核对定价梯度是否连贯,并同步观察库存结构与转化表现是否匹配。', [ ['label' => '继续查看当前价格带商品', 'url' => $currentUrl], ['label' => '去看最近新增商品', 'url' => $links['latest']], ] ); } if ($isPublished) { return $response( '当前正在查看已上架商品,建议优先关注库存结构、价格带与分类覆盖是否均衡。', [ ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], ['label' => '继续查看已上架商品', 'url' => $currentUrl], ] ); } if (($summaryStats['total_products'] ?? 0) <= 0) { return $response( '当前商家暂无商品,建议先补齐基础商品数据,再开始做上架与库存运营。', [ ['label' => '先看商品空白情况', 'url' => $links['latest']], ['label' => '查看草稿商品', 'url' => $links['draft']], ] ); } if (($summaryStats['total_products'] ?? 0) < 3) { return $response( '当前商家商品仍较少,建议优先查看最近新增与已上架商品,确认基础信息是否完整。', [ ['label' => '去看最近新增商品', 'url' => $links['latest']], ['label' => '去看已上架商品', 'url' => $links['published_stock_desc']], ] ); } return $response( $lowStockCount > 0 ? '当前商家商品已形成基础规模,建议优先巡检低库存商品,并同步关注高库存结构是否均衡。' : '当前商家商品已形成基础规模,建议优先关注高库存结构与最近新增商品质量。', $lowStockCount > 0 ? [ ['label' => '去看低库存商品', 'url' => $links['published_stock_asc']], ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], ] : [ ['label' => '去看高库存商品', 'url' => $links['published_stock_desc']], ['label' => '去看最近新增商品', 'url' => $links['latest']], ] ); } protected function buildUrl(string $path, array $filters): string { $query = http_build_query(array_filter($filters, fn ($value) => $value !== null && $value !== '' && $value !== 'latest')); return $path . ($query !== '' ? ('?' . $query) : ''); } protected function buildImportHistorySummaryData(int $merchantId): array { $histories = ProductImportHistory::query() ->where('scope', 'merchant') ->where('merchant_id', $merchantId) ->with(['admin']) ->orderByDesc('imported_at') ->orderByDesc('id') ->take(20) ->get(); $this->hydrateFailureAvailability($histories); $stats = ProductImportHistory::query() ->where('scope', 'merchant') ->where('merchant_id', $merchantId) ->selectRaw('COUNT(*) as total_imports') ->selectRaw('COALESCE(SUM(success_count), 0) as total_success') ->selectRaw('COALESCE(SUM(failed_count), 0) as total_failed') ->selectRaw('SUM(CASE WHEN failed_count > 0 THEN 1 ELSE 0 END) as warning_imports') ->first(); return [ 'histories' => $histories, 'stats' => [ 'total_imports' => (int) ($stats->total_imports ?? 0), 'total_success' => (int) ($stats->total_success ?? 0), 'total_failed' => (int) ($stats->total_failed ?? 0), 'warning_imports' => (int) ($stats->warning_imports ?? 0), ], ]; } protected function hydrateFailureAvailability($histories): void { $disk = Storage::disk('local'); $base = 'private/imports/product-failures/'; foreach ($histories as $history) { $history->failure_file_available = $history->failure_file ? $disk->exists($base . basename((string) $history->failure_file)) : false; } } protected function flushMerchantCaches(int $merchantId): void { Cache::add(CacheKeys::merchantProductsVersion($merchantId), 1, now()->addDays(30)); Cache::increment(CacheKeys::merchantProductsVersion($merchantId)); Cache::forget(CacheKeys::merchantDashboardStats($merchantId)); } protected function importTemplateHeader(): array { return [ 'title', 'slug', 'sku', 'price', 'original_price', 'stock', 'category_slug', 'summary', 'status', ]; } protected function parseCsvRow(array $row, array $header): array { $data = []; foreach ($header as $index => $column) { $data[$column] = $row[$index] ?? null; } return $data; } protected function buildImportFailureRow(int $line, array $data, string $error): array { return [ 'line' => $line, 'data' => $data, 'error' => $error, ]; } protected function storeImportFailuresCsv(string $scope, array $header, array $failures): string { $fileName = $scope . '_product_import_failures_' . now()->format('Ymd_His') . '_' . substr(md5((string) microtime(true)), 0, 6) . '.csv'; $path = 'private/imports/product-failures/' . $fileName; $lines = []; $lines[] = implode(',', array_merge($header, ['error'])); foreach ($failures as $failure) { $data = $failure['data'] ?? []; $row = []; foreach ($header as $column) { $row[] = (string) ($data[$column] ?? ''); } $row[] = (string) ($failure['error'] ?? ''); $lines[] = $this->csvLine($row); } Storage::disk('local')->put($path, "\xEF\xBB\xBF" . implode("\n", $lines)); return $fileName; } protected function csvLine(array $fields): string { $handle = fopen('php://temp', 'r+'); fputcsv($handle, $fields); rewind($handle); $line = stream_get_contents($handle); fclose($handle); return rtrim((string) $line, "\n\r"); } protected function importProductRow(array $data, int $merchantId, int $line): void { $title = trim((string) ($data['title'] ?? '')); $slug = trim((string) ($data['slug'] ?? '')); if ($title === '') { throw new \InvalidArgumentException("第 {$line} 行 title 不能为空"); } if ($slug === '') { throw new \InvalidArgumentException("第 {$line} 行 slug 不能为空"); } $categoryId = null; $categorySlug = trim((string) ($data['category_slug'] ?? '')); if ($categorySlug !== '') { $category = ProductCategory::query()->where('merchant_id', $merchantId)->where('slug', $categorySlug)->first(); if (! $category) { throw new \InvalidArgumentException("第 {$line} 行 category_slug 不存在"); } $categoryId = $category->id; } $status = trim((string) ($data['status'] ?? 'published')); if (! in_array($status, $this->statusOptions, true)) { $status = 'published'; } $price = is_numeric($data['price'] ?? null) ? (float) $data['price'] : 0.0; $originalPrice = is_numeric($data['original_price'] ?? null) ? (float) $data['original_price'] : $price; $stock = filter_var($data['stock'] ?? 0, FILTER_VALIDATE_INT) !== false ? (int) $data['stock'] : 0; Product::query()->forMerchant($merchantId)->updateOrCreate([ 'merchant_id' => $merchantId, 'slug' => $slug, ], [ 'category_id' => $categoryId, 'title' => $title, 'sku' => trim((string) ($data['sku'] ?? '')), 'summary' => trim((string) ($data['summary'] ?? '')), 'content' => '', 'price' => $price, 'original_price' => $originalPrice, 'stock' => $stock, 'status' => $status, 'images' => [], ]); } protected function importHistoryFilters(Request $request): array { $resultStatus = trim((string) $request->string('import_result_status', 'all')); $timeRange = trim((string) $request->string('import_time_range', 'all')); $rawStart = trim((string) $request->string('start_date')); $rawEnd = trim((string) $request->string('end_date')); $sort = trim((string) $request->string('import_sort', 'latest')); $dateErrors = []; $startDate = null; $endDate = null; if ($timeRange === 'today') { $startDate = now()->toDateString(); $endDate = now()->toDateString(); } elseif ($timeRange === 'last_7_days') { $startDate = now()->subDays(6)->toDateString(); $endDate = now()->toDateString(); } elseif ($timeRange === 'custom') { if ($rawStart !== '' && ! $this->isValidDate($rawStart)) { $dateErrors[] = '开始日期格式不正确,请使用 YYYY-MM-DD。'; } if ($rawEnd !== '' && ! $this->isValidDate($rawEnd)) { $dateErrors[] = '结束日期格式不正确,请使用 YYYY-MM-DD。'; } if ($rawStart !== '' && $rawEnd !== '' && $this->isValidDate($rawStart) && $this->isValidDate($rawEnd) && $rawStart > $rawEnd) { $dateErrors[] = '开始日期不能晚于结束日期。'; } $startDate = $rawStart !== '' ? $rawStart : null; $endDate = $rawEnd !== '' ? $rawEnd : null; } return [ 'result_status' => in_array($resultStatus, ['all', 'success_only', 'has_failures'], true) ? $resultStatus : 'all', 'time_range' => in_array($timeRange, ['all', 'today', 'last_7_days', 'custom'], true) ? $timeRange : 'all', 'start_date' => $startDate, 'end_date' => $endDate, 'sort' => in_array($sort, ['latest', 'oldest'], true) ? $sort : 'latest', 'date_errors' => $dateErrors, ]; } protected function isValidDate(string $value): bool { try { $date = Carbon::createFromFormat('Y-m-d', $value); } catch (\Throwable $exception) { return false; } return $date && $date->format('Y-m-d') === $value; } }