普通商品数据来源列与筛选结果导出 #76
@ -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<string,mixed> */
|
||||
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,
|
||||
|
||||
@ -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<string,mixed> $filters
|
||||
* @return Generator<int,object>
|
||||
*/
|
||||
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<object> $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<string,mixed> $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<object> $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} */
|
||||
|
||||
@ -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 @@
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">search</span>
|
||||
查询
|
||||
</button>
|
||||
<a
|
||||
class="product-catalog-button"
|
||||
href="{{ route('business.product-inventory.normal-products.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}"
|
||||
aria-label="导出当前筛选结果"
|
||||
title="导出当前筛选结果"
|
||||
>
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">download</span>
|
||||
导出
|
||||
</a>
|
||||
<a
|
||||
class="product-catalog-button"
|
||||
href="{{ route('business.pages.show', ['company_id' => company_id(), 'section' => $section['slug'], 'page' => $page['slug']]) }}"
|
||||
@ -496,11 +505,11 @@
|
||||
<span class="material-icons-outlined text-base" aria-hidden="true">schedule</span>
|
||||
<span>
|
||||
@if (($productCatalogPurchasePriceSyncStatus['code'] ?? '') === 'initializing')
|
||||
1688 代发单价正在执行首次同步,完成前当前仅显示普通商品采购价。
|
||||
1688代发单价正在执行首次同步,完成前当前仅显示普通商品采购价。
|
||||
@elseif (($productCatalogPurchasePriceSyncStatus['code'] ?? '') === 'initial_failed')
|
||||
1688 代发单价首次同步失败,当前仅显示普通商品采购价。
|
||||
1688代发单价首次同步失败,当前仅显示普通商品采购价。
|
||||
@else
|
||||
1688 代发单价尚未完成首次同步,当前仅显示普通商品采购价。
|
||||
1688代发单价尚未完成首次同步,当前仅显示普通商品采购价。
|
||||
@endif
|
||||
</span>
|
||||
</div>
|
||||
@ -522,7 +531,8 @@
|
||||
<th class="is-numeric">销售价</th>
|
||||
<th class="is-numeric">成本价</th>
|
||||
<th class="is-numeric">普通商品采购价</th>
|
||||
<th class="is-numeric">1688 代发单价</th>
|
||||
<th class="is-numeric">1688代发单价</th>
|
||||
<th>数据来源</th>
|
||||
<th class="is-numeric">市场价</th>
|
||||
<th>商品状态</th>
|
||||
<th>库存同步</th>
|
||||
@ -555,13 +565,14 @@
|
||||
@foreach (['sale_price', 'cost_price'] as $priceField)
|
||||
<td class="is-numeric">{{ isset($row->{$priceField}) && $row->{$priceField} !== null ? number_format((float) $row->{$priceField}, 2) : '-' }}</td>
|
||||
@endforeach
|
||||
<td class="is-numeric product-catalog-price-cell">
|
||||
{{ isset($row->purchase_price) && $row->purchase_price !== null ? number_format((float) $row->purchase_price, 2) : '-' }}
|
||||
<span class="product-catalog-price-source">{{ $row->product_purchase_price_source ?? $productCatalogProductPriceSource }}</span>
|
||||
</td>
|
||||
<td class="is-numeric product-catalog-price-cell" title="{{ $row->purchase_order_finished_at ?? '' }}">
|
||||
{{ isset($row->purchase_order_unit_price) && $row->purchase_order_unit_price !== null ? number_format((float) $row->purchase_order_unit_price, 2) : '-' }}
|
||||
<span class="product-catalog-price-source">{{ $row->purchase_order_price_source ?? $productCatalogPurchasePriceSource }}</span>
|
||||
<td class="is-numeric">{{ isset($row->purchase_price) && $row->purchase_price !== null ? number_format((float) $row->purchase_price, 2) : '-' }}</td>
|
||||
<td class="is-numeric" title="{{ $row->purchase_order_finished_at ?? '' }}">{{ isset($row->purchase_order_unit_price) && $row->purchase_order_unit_price !== null ? number_format((float) $row->purchase_order_unit_price, 2) : '-' }}</td>
|
||||
<td class="product-catalog-source-list">
|
||||
@forelse ($row->data_sources ?? [] as $source)
|
||||
<span class="product-catalog-source-item">{{ $source }}</span>
|
||||
@empty
|
||||
-
|
||||
@endforelse
|
||||
</td>
|
||||
<td class="is-numeric">{{ isset($row->market_price) && $row->market_price !== null ? number_format((float) $row->market_price, 2) : '-' }}</td>
|
||||
<td>
|
||||
@ -578,7 +589,7 @@
|
||||
</tr>
|
||||
@empty
|
||||
<tr>
|
||||
<td colspan="17" class="product-catalog-empty">
|
||||
<td colspan="18" class="product-catalog-empty">
|
||||
<span class="material-icons-outlined text-3xl block mb-2" aria-hidden="true">inventory_2</span>
|
||||
@if ($productCatalogError)
|
||||
数据暂时不可用。
|
||||
|
||||
@ -88,6 +88,10 @@ Route::prefix('workspace/courier-payables/quote-management/signatures')
|
||||
Route::get('{quoteSignature}/files/{kind}', [CourierQuoteSignatures::class, 'download'])->name('download');
|
||||
});
|
||||
|
||||
Route::get('workspace/product-inventory/normal-products/export', 'Business\Pages@exportNormalProducts')
|
||||
->middleware('business.page:product-inventory,normal-products')
|
||||
->name('business.product-inventory.normal-products.export');
|
||||
|
||||
Route::get('workspace/{section}/{page}/{tab}/export', 'Business\Pages@export')
|
||||
->name('business.pages.export');
|
||||
|
||||
|
||||
@ -58,11 +58,10 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
'sale_price' => 899,
|
||||
'cost_price' => 500,
|
||||
'purchase_price' => 480,
|
||||
'product_purchase_price_source' => '商品及库存管理(普通商品资料)',
|
||||
'purchase_order_unit_price' => 65,
|
||||
'purchase_order_finished_at' => '2026-08-20 11:03:05',
|
||||
'purchase_order_id' => 827380,
|
||||
'purchase_order_price_source' => '采购单管理-1688代发单价',
|
||||
'data_sources' => ['商品及库存管理(普通商品资料)', '1688代发单价'],
|
||||
'market_price' => 1099,
|
||||
'brand' => '北木',
|
||||
'category' => '住宅家具',
|
||||
@ -106,15 +105,73 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
->assertSeeText('岩板餐桌')
|
||||
->assertSeeText('899.00')
|
||||
->assertSeeText('普通商品采购价')
|
||||
->assertSeeText('1688 代发单价')
|
||||
->assertSeeText('1688代发单价')
|
||||
->assertSeeText('数据来源')
|
||||
->assertSeeText('商品及库存管理(普通商品资料)')
|
||||
->assertSeeText('采购单管理-1688代发单价')
|
||||
->assertSeeText('1688代发单价')
|
||||
->assertSeeText('65.00')
|
||||
->assertDontSeeText('1688 代发单价尚未完成首次同步')
|
||||
->assertSeeText('导出')
|
||||
->assertDontSeeText('1688代发单价尚未完成首次同步')
|
||||
->assertSeeText('正常同步')
|
||||
->assertDontSee('pic_url');
|
||||
}
|
||||
|
||||
public function test_normal_products_export_uses_all_filters_and_includes_data_source_column(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
|
||||
$repository = \Mockery::mock(ProductCatalogMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('ready')->once()->andReturnTrue();
|
||||
$repository->shouldReceive('exportRows')
|
||||
->once()
|
||||
->withArgs(static function (array $filters): bool {
|
||||
return $filters['sku_id'] === 'SKU-001'
|
||||
&& $filters['supplier_name'] === '新北供应商'
|
||||
&& $filters['sale_price_min'] === '800'
|
||||
&& $filters['sale_price_max'] === '900';
|
||||
})
|
||||
->andReturn((static function (): \Generator {
|
||||
yield (object) [
|
||||
'autoid' => 1001,
|
||||
'i_id' => 'STYLE-001',
|
||||
'sku_id' => 'SKU-001',
|
||||
'name' => '岩板餐桌',
|
||||
'short_name' => '餐桌',
|
||||
'properties_value' => '1.4米',
|
||||
'color' => '胡桃色',
|
||||
'brand' => '北木',
|
||||
'category' => '住宅家具',
|
||||
'supplier_name' => '新北供应商',
|
||||
'sale_price' => 899,
|
||||
'cost_price' => 500,
|
||||
'purchase_price' => 480,
|
||||
'purchase_order_unit_price' => 65,
|
||||
'data_sources' => ['商品及库存管理(普通商品资料)', '1688代发单价'],
|
||||
'market_price' => 1099,
|
||||
'enabled' => 1,
|
||||
'stock_disabled' => 0,
|
||||
'modified' => '2026-08-15 10:00:00',
|
||||
];
|
||||
})());
|
||||
$this->app->instance(ProductCatalogMirrorRepository::class, $repository);
|
||||
|
||||
$response = $this->loginAs()->get(route('business.product-inventory.normal-products.export', [
|
||||
'sku_id' => 'SKU-001',
|
||||
'supplier_name' => '新北供应商',
|
||||
'sale_price_min' => '900',
|
||||
'sale_price_max' => '800',
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$this->assertStringContainsString('attachment;', (string) $response->headers->get('content-disposition'));
|
||||
$content = $response->streamedContent();
|
||||
$this->assertStringStartsWith(chr(0xEF) . chr(0xBB) . chr(0xBF), $content);
|
||||
$this->assertStringContainsString('数据来源', $content);
|
||||
$this->assertStringContainsString('商品及库存管理(普通商品资料);1688代发单价', $content);
|
||||
$this->assertStringContainsString('SKU-001', $content);
|
||||
}
|
||||
|
||||
public function test_normal_products_page_marks_purchase_prices_as_not_yet_synchronized(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
@ -150,7 +207,7 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
]))
|
||||
->assertOk()
|
||||
->assertSee('data-purchase-price-sync-state="pending"', false)
|
||||
->assertSeeText('1688 代发单价尚未完成首次同步')
|
||||
->assertSeeText('1688代发单价尚未完成首次同步')
|
||||
->assertSeeText('当前仅显示普通商品采购价');
|
||||
}
|
||||
|
||||
@ -254,6 +311,39 @@ class ProductCatalogPageTest extends FeatureTestCase
|
||||
->assertSeeText('最近成功同步的商品');
|
||||
}
|
||||
|
||||
public function test_normal_products_export_reports_an_unavailable_mirror(): void
|
||||
{
|
||||
$this->grantAdminPermission();
|
||||
|
||||
$repository = \Mockery::mock(ProductCatalogMirrorRepository::class);
|
||||
$repository->shouldReceive('available')->once()->andReturnFalse();
|
||||
$repository->shouldNotReceive('ready');
|
||||
$repository->shouldNotReceive('exportRows');
|
||||
$this->app->instance(ProductCatalogMirrorRepository::class, $repository);
|
||||
|
||||
$this->withExceptionHandling()
|
||||
->loginAs()
|
||||
->get(route('business.product-inventory.normal-products.export'))
|
||||
->assertServiceUnavailable();
|
||||
}
|
||||
|
||||
public function test_normal_products_export_rejects_users_without_sidebar_permission(): void
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
setting()->set('system.sidebar_permissions', json_encode([
|
||||
'configured' => [$subject],
|
||||
'subjects' => [
|
||||
$subject => ['company-management/basic-information'],
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$this->withExceptionHandling()
|
||||
->loginAs()
|
||||
->get(route('business.product-inventory.normal-products.export'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_normal_products_page_rejects_users_without_sidebar_permission(): void
|
||||
{
|
||||
$subject = 'user_' . $this->user->id;
|
||||
|
||||
@ -151,8 +151,10 @@ class ProductCatalogMirrorRepositoryTest extends TestCase
|
||||
$this->assertSame('55', (string) $row->purchase_price);
|
||||
$this->assertSame('65', (string) $row->purchase_order_unit_price);
|
||||
$this->assertSame(1011, (int) $row->purchase_order_id);
|
||||
$this->assertSame('采购单管理-1688代发单价', $row->purchase_order_price_source);
|
||||
$this->assertSame('商品及库存管理(普通商品资料)', $row->product_purchase_price_source);
|
||||
$this->assertSame(
|
||||
['商品及库存管理(普通商品资料)', '1688代发单价'],
|
||||
$row->data_sources,
|
||||
);
|
||||
$this->assertTrue($result['purchase_price_sync_status']['initialized']);
|
||||
}
|
||||
|
||||
@ -167,11 +169,31 @@ class ProductCatalogMirrorRepositoryTest extends TestCase
|
||||
|
||||
$this->assertSame('SKU-PENDING', $row->sku_id);
|
||||
$this->assertNull($row->purchase_order_unit_price);
|
||||
$this->assertSame('采购单管理-1688代发单价', $row->purchase_order_price_source);
|
||||
$this->assertSame(['商品及库存管理(普通商品资料)'], $row->data_sources);
|
||||
$this->assertFalse($result['purchase_price_sync_status']['initialized']);
|
||||
$this->assertSame('pending', $result['purchase_price_sync_status']['code']);
|
||||
}
|
||||
|
||||
public function test_it_exports_all_filtered_rows_in_stable_batches_with_data_sources(): void
|
||||
{
|
||||
$this->markReady(3);
|
||||
$this->insertProduct(30, ['sku_id' => 'SKU-30', 'brand' => '保留品牌', 'purchase_price' => null]);
|
||||
$this->insertProduct(10, ['sku_id' => 'SKU-10', 'brand' => '保留品牌', 'purchase_price' => 55]);
|
||||
$this->insertProduct(20, ['sku_id' => 'SKU-20', 'brand' => '排除品牌']);
|
||||
$this->createPurchasePriceTables(true);
|
||||
$this->insertPurchasePrice(30, ['sku_id' => 'SKU-30', 'unit_price' => 66]);
|
||||
|
||||
$rows = iterator_to_array($this->repository->exportRows(
|
||||
$this->filters(['brand' => '保留品牌']),
|
||||
1,
|
||||
));
|
||||
|
||||
$this->assertSame([10, 30], array_map(static fn (object $row): int => $row->autoid, $rows));
|
||||
$this->assertSame(['商品及库存管理(普通商品资料)'], $rows[0]->data_sources);
|
||||
$this->assertSame(['1688代发单价'], $rows[1]->data_sources);
|
||||
$this->assertSame('66', (string) $rows[1]->purchase_order_unit_price);
|
||||
}
|
||||
|
||||
public function test_it_refuses_queries_before_the_initial_sync_completes(): void
|
||||
{
|
||||
DB::connection('business_mirror')->table('business_mirror_table_states')->insert([
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user