From 448b75c99cc4c0dcf2310964fd45c6cb03db84f6 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Mon, 24 Aug 2026 14:41:17 +0800 Subject: [PATCH] =?UTF-8?q?=E6=99=AE=E9=80=9A=E5=95=86=E5=93=81=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=95=B0=E6=8D=AE=E6=9D=A5=E6=BA=90=E5=88=97=E5=92=8C?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Http/Controllers/Business/Pages.php | 129 ++++++++--- .../ProductCatalogMirrorRepository.php | 202 +++++++++++------- .../normal-products.blade.php | 53 +++-- routes/admin.php | 4 + .../Business/ProductCatalogPageTest.php | 102 ++++++++- .../ProductCatalogMirrorRepositoryTest.php | 28 ++- 6 files changed, 384 insertions(+), 134 deletions(-) diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 20046603..a5619bd5 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -26,6 +26,7 @@ use Modules\EcommerceFinance\Repositories\RawBoardPayablesRepository; use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository; use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository; use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory; +use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Throwable; @@ -823,31 +824,7 @@ class Pages extends Controller protected function contentForNormalProducts(): array { - $filters = [ - 'i_id' => mb_substr(trim((string) request('i_id', '')), 0, 50), - 'sku_id' => mb_substr(trim((string) request('sku_id', '')), 0, 50), - 'keyword' => mb_substr(trim((string) request('keyword', '')), 0, 100), - 'brand' => mb_substr(trim((string) request('brand', '')), 0, 100), - 'category' => mb_substr(trim((string) request('category', '')), 0, 100), - 'supplier_name' => mb_substr(trim((string) request('supplier_name', '')), 0, 191), - 'enabled' => in_array((string) request('enabled', ''), ['-1', '0', '1'], true) - ? (string) request('enabled') - : '', - 'stock_disabled' => in_array((string) request('stock_disabled', ''), ['0', '1'], true) - ? (string) request('stock_disabled') - : '', - 'sale_price_min' => $this->decimalFilter(request('sale_price_min')), - 'sale_price_max' => $this->decimalFilter(request('sale_price_max')), - ]; - - if ($filters['sale_price_min'] !== null - && $filters['sale_price_max'] !== null - && (float) $filters['sale_price_min'] > (float) $filters['sale_price_max']) { - [$filters['sale_price_min'], $filters['sale_price_max']] = [ - $filters['sale_price_max'], - $filters['sale_price_min'], - ]; - } + $filters = $this->normalProductFilters(request()); $page = max((int) request('page', 1), 1); $limit = min(max((int) setting('default.list_limit', 25), 1), 50); @@ -895,8 +872,6 @@ class Pages extends Controller 'productCatalogError' => null, 'productCatalogState' => $state, 'productCatalogSyncStatus' => $syncStatus, - 'productCatalogProductPriceSource' => ProductCatalogMirrorRepository::PRODUCT_SOURCE_LABEL, - 'productCatalogPurchasePriceSource' => ProductCatalogMirrorRepository::PURCHASE_SOURCE_LABEL, 'productCatalogPurchasePriceSyncStatus' => $result['purchase_price_sync_status'] ?? [ 'code' => 'unavailable', 'initialized' => false, @@ -917,6 +892,104 @@ class Pages extends Controller } } + public function exportNormalProducts(Request $request): StreamedResponse + { + abort_unless($this->productCatalog->available() && $this->productCatalog->ready(), 503); + + $filters = $this->normalProductFilters($request); + + return response()->streamDownload(function () use ($filters): void { + $csv = fopen('php://output', 'w'); + if ($csv === false) { + throw new \RuntimeException('无法创建普通商品资料导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, [ + 'ID', '款式编码', '商品编码', '商品名称', '商品简称', '规格 / 颜色', + '品牌', '类目', '供应商', '销售价', '成本价', '普通商品采购价', + '1688代发单价', '数据来源', '市场价', '商品状态', '库存同步', '业务更新时间', + ]); + + foreach ($this->productCatalog->exportRows($filters) as $row) { + $specification = collect([$row->properties_value, $row->color]) + ->filter(static fn (mixed $value): bool => $value !== null && $value !== '') + ->unique() + ->implode(' / '); + $enabledLabel = match ((int) $row->enabled) { + 1 => '已启用', + -1 => '已删除', + default => '已停用', + }; + + fputcsv($csv, [ + $row->autoid, + $this->csvText($row->i_id), + $this->csvText($row->sku_id), + $this->csvText($row->name), + $this->csvText($row->short_name), + $this->csvText($specification), + $this->csvText($row->brand), + $this->csvText($row->category), + $this->csvText($row->supplier_name), + $row->sale_price, + $row->cost_price, + $row->purchase_price, + $row->purchase_order_unit_price, + implode(';', $row->data_sources), + $row->market_price, + $enabledLabel, + (int) $row->stock_disabled === 0 ? '正常同步' : '停止同步', + $row->modified, + ]); + } + + fclose($csv); + }, '普通商品资料_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + /** @return array */ + private function normalProductFilters(Request $request): array + { + $filters = [ + 'i_id' => mb_substr(trim((string) $request->query('i_id', '')), 0, 50), + 'sku_id' => mb_substr(trim((string) $request->query('sku_id', '')), 0, 50), + 'keyword' => mb_substr(trim((string) $request->query('keyword', '')), 0, 100), + 'brand' => mb_substr(trim((string) $request->query('brand', '')), 0, 100), + 'category' => mb_substr(trim((string) $request->query('category', '')), 0, 100), + 'supplier_name' => mb_substr(trim((string) $request->query('supplier_name', '')), 0, 191), + 'enabled' => in_array((string) $request->query('enabled', ''), ['-1', '0', '1'], true) + ? (string) $request->query('enabled') + : '', + 'stock_disabled' => in_array((string) $request->query('stock_disabled', ''), ['0', '1'], true) + ? (string) $request->query('stock_disabled') + : '', + 'sale_price_min' => $this->decimalFilter($request->query('sale_price_min')), + 'sale_price_max' => $this->decimalFilter($request->query('sale_price_max')), + ]; + + if ($filters['sale_price_min'] !== null + && $filters['sale_price_max'] !== null + && (float) $filters['sale_price_min'] > (float) $filters['sale_price_max']) { + [$filters['sale_price_min'], $filters['sale_price_max']] = [ + $filters['sale_price_max'], + $filters['sale_price_min'], + ]; + } + + return $filters; + } + + private function csvText(mixed $value): string + { + $text = trim((string) ($value ?? '')); + + return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text; + } + private function emptyProductCatalogContent( array $filters, int $limit, @@ -940,8 +1013,6 @@ class Pages extends Controller 'productCatalogError' => $error, 'productCatalogState' => $state, 'productCatalogSyncStatus' => $syncStatus, - 'productCatalogProductPriceSource' => ProductCatalogMirrorRepository::PRODUCT_SOURCE_LABEL, - 'productCatalogPurchasePriceSource' => ProductCatalogMirrorRepository::PURCHASE_SOURCE_LABEL, 'productCatalogPurchasePriceSyncStatus' => [ 'code' => 'unavailable', 'initialized' => false, diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php index 9ccfbfa7..55c17d60 100644 --- a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -3,7 +3,9 @@ namespace Modules\EcommerceFinance\Repositories; use Carbon\CarbonInterface; +use Generator; use Illuminate\Database\ConnectionInterface; +use Illuminate\Database\Query\Builder; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset; @@ -17,7 +19,7 @@ class ProductCatalogMirrorRepository public const PRODUCT_SOURCE_LABEL = '商品及库存管理(普通商品资料)'; - public const PURCHASE_SOURCE_LABEL = '采购单管理-1688代发单价'; + public const PURCHASE_SOURCE_LABEL = '1688代发单价'; private readonly ConnectionInterface $connection; @@ -91,6 +93,129 @@ class ProductCatalogMirrorRepository $page = max($page, 1); $perPage = min(max($perPage, 1), 50); + $query = $this->filteredQuery($filters); + + $total = (clone $query)->count(); + $rows = $query->orderBy('autoid') + ->forPage($page, $perPage) + ->get() + ->all(); + + $purchasePriceSyncStatus = $this->purchasePriceSyncStatus(); + $this->attachPurchasePrices($rows, $purchasePriceSyncStatus['initialized']); + + return [ + 'data' => $rows, + 'total' => $total, + 'purchase_price_sync_status' => $purchasePriceSyncStatus, + ]; + } + + /** + * @param array $filters + * @return Generator + */ + public function exportRows(array $filters, int $batchSize = 1000): Generator + { + if (! $this->ready()) { + throw new RuntimeException('business_mirror_dataset_not_ready'); + } + + $batchSize = min(max($batchSize, 1), 5000); + $purchasePriceSyncStatus = $this->purchasePriceSyncStatus(); + $lastAutoid = null; + + while (true) { + $query = $this->filteredQuery($filters); + if ($lastAutoid !== null) { + $query->where('autoid', '>', $lastAutoid); + } + + $rows = $query->orderBy('autoid') + ->limit($batchSize) + ->get() + ->all(); + if ($rows === []) { + return; + } + + $this->attachPurchasePrices($rows, $purchasePriceSyncStatus['initialized']); + foreach ($rows as $row) { + yield $row; + } + + $lastAutoid = (int) $rows[array_key_last($rows)]->autoid; + if (count($rows) < $batchSize) { + return; + } + } + } + + /** @param list $rows */ + private function attachPurchasePrices(array $rows, bool $initialized): void + { + foreach ($rows as $row) { + $row->purchase_order_unit_price = null; + $row->purchase_order_finished_at = null; + $row->purchase_order_id = null; + $row->data_sources = $row->purchase_price === null + ? [] + : [self::PRODUCT_SOURCE_LABEL]; + } + + if ($rows === []) { + return; + } + + if (! $initialized) { + return; + } + + $skuIds = collect($rows) + ->pluck('sku_id') + ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') + ->map(static fn (string $value): string => trim($value)) + ->unique() + ->values() + ->all(); + if ($skuIds === []) { + return; + } + + $candidates = $this->connection->table('product_purchase_price_mirror_rows') + ->where('document_status', 'Finished') + ->where('labels', 'like', '%1688一键代发%') + ->whereNotNull('unit_price') + ->whereIn('sku_id', $skuIds) + ->orderBy('sku_id') + ->orderByDesc('finished_at') + ->orderByDesc('purchase_order_id') + ->orderByDesc('item_id') + ->get([ + 'sku_id', + 'unit_price', + 'finished_at', + 'purchase_order_id', + ]) + ->unique('sku_id') + ->keyBy('sku_id'); + + foreach ($rows as $row) { + $purchase = $candidates->get(trim((string) ($row->sku_id ?? ''))); + if ($purchase === null) { + continue; + } + + $row->purchase_order_unit_price = $purchase->unit_price; + $row->purchase_order_finished_at = $purchase->finished_at; + $row->purchase_order_id = $purchase->purchase_order_id; + $row->data_sources[] = self::PURCHASE_SOURCE_LABEL; + } + } + + /** @param array $filters */ + private function filteredQuery(array $filters): Builder + { $query = $this->connection->table($this->dataset()->targetTable()) ->where('sku_type', 'normal') ->select([ @@ -149,80 +274,7 @@ class ProductCatalogMirrorRepository $query->where('sale_price', '<=', $filters['sale_price_max']); } - $total = (clone $query)->count(); - $rows = $query->orderBy('autoid') - ->forPage($page, $perPage) - ->get() - ->all(); - - $purchasePriceSyncStatus = $this->purchasePriceSyncStatus(); - $this->attachPurchasePrices($rows, $purchasePriceSyncStatus['initialized']); - - return [ - 'data' => $rows, - 'total' => $total, - 'purchase_price_sync_status' => $purchasePriceSyncStatus, - ]; - } - - /** @param list $rows */ - private function attachPurchasePrices(array $rows, bool $initialized): void - { - foreach ($rows as $row) { - $row->product_purchase_price_source = self::PRODUCT_SOURCE_LABEL; - $row->purchase_order_unit_price = null; - $row->purchase_order_finished_at = null; - $row->purchase_order_id = null; - $row->purchase_order_price_source = self::PURCHASE_SOURCE_LABEL; - } - - if ($rows === []) { - return; - } - - if (! $initialized) { - return; - } - - $skuIds = collect($rows) - ->pluck('sku_id') - ->filter(static fn (mixed $value): bool => is_string($value) && trim($value) !== '') - ->map(static fn (string $value): string => trim($value)) - ->unique() - ->values() - ->all(); - if ($skuIds === []) { - return; - } - - $candidates = $this->connection->table('product_purchase_price_mirror_rows') - ->where('document_status', 'Finished') - ->where('labels', 'like', '%1688一键代发%') - ->whereNotNull('unit_price') - ->whereIn('sku_id', $skuIds) - ->orderBy('sku_id') - ->orderByDesc('finished_at') - ->orderByDesc('purchase_order_id') - ->orderByDesc('item_id') - ->get([ - 'sku_id', - 'unit_price', - 'finished_at', - 'purchase_order_id', - ]) - ->unique('sku_id') - ->keyBy('sku_id'); - - foreach ($rows as $row) { - $purchase = $candidates->get(trim((string) ($row->sku_id ?? ''))); - if ($purchase === null) { - continue; - } - - $row->purchase_order_unit_price = $purchase->unit_price; - $row->purchase_order_finished_at = $purchase->finished_at; - $row->purchase_order_id = $purchase->purchase_order_id; - } + return $query; } /** @return array{code:string,initialized:bool,last_succeeded_at:?string} */ diff --git a/resources/views/business/pages/product-inventory/normal-products.blade.php b/resources/views/business/pages/product-inventory/normal-products.blade.php index a53b16fb..56e6e213 100644 --- a/resources/views/business/pages/product-inventory/normal-products.blade.php +++ b/resources/views/business/pages/product-inventory/normal-products.blade.php @@ -4,8 +4,6 @@ $productCatalogError = $content['productCatalogError'] ?? null; $productCatalogState = $content['productCatalogState'] ?? null; $productCatalogSyncStatus = $content['productCatalogSyncStatus'] ?? ['code' => 'unavailable', 'lag_minutes' => null]; - $productCatalogProductPriceSource = $content['productCatalogProductPriceSource'] ?? '商品及库存管理(普通商品资料)'; - $productCatalogPurchasePriceSource = $content['productCatalogPurchasePriceSource'] ?? '采购单管理-1688代发单价'; $productCatalogPurchasePriceSyncStatus = $content['productCatalogPurchasePriceSyncStatus'] ?? [ 'code' => 'unavailable', 'initialized' => false, @@ -260,19 +258,21 @@ white-space: nowrap; } - .product-catalog-price-cell { - min-width: 168px; + .product-catalog-source-list { + min-width: 210px; + color: var(--catalog-muted); + line-height: 1.4; } - .product-catalog-price-source { + .product-catalog-source-item { display: block; - margin-top: 3px; - color: var(--catalog-muted); - font-size: 0.6875rem; - line-height: 1.25; white-space: nowrap; } + .product-catalog-source-item + .product-catalog-source-item { + margin-top: 2px; + } + .product-catalog-table .is-code { font-variant-numeric: tabular-nums; white-space: nowrap; @@ -470,6 +470,15 @@ 查询 + + + 导出 +