diff --git a/app/Http/Controllers/Business/ProductPrices.php b/app/Http/Controllers/Business/ProductPrices.php new file mode 100644 index 00000000..d506478d --- /dev/null +++ b/app/Http/Controllers/Business/ProductPrices.php @@ -0,0 +1,189 @@ +middleware('auth'); + $this->middleware('company.identify'); + } + + public function export(Request $request): StreamedResponse|RedirectResponse + { + if (! $this->repository->ready() || ! $this->repository->initialized()) { + return $this->redirectToPage($request) + ->with('error', '商品价格镜像尚未完成首次同步,暂时不能导出。'); + } + + $filters = $this->filters($request); + $companyId = (int) (company_id() ?: 1); + + return response()->streamDownload(function () use ($filters, $companyId): void { + $csv = fopen('php://output', 'w'); + + if ($csv === false) { + throw new RuntimeException('无法创建商品价格导出流。'); + } + + fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF)); + fputcsv($csv, [ + '记录标识', '业务编号', '商品编码', 'SPU', '商品名称', '类目', '生产状态', + '最低售价', '计算限价', '采购价', '运费', '京喜限价', '流程状态', + '审批结果', '流程标题', '流程完成时间', '人工维护', '维护时间', + ]); + + foreach ($this->repository->exportRows($filters, $companyId) as $row) { + fputcsv($csv, [ + $this->csvText($row->upstream_key ?? ''), + $this->csvText($row->business_id ?? ''), + $this->csvText($row->barcode ?? ''), + $this->csvText($row->spu ?? ''), + $this->csvText($row->product_name ?? ''), + $this->csvText($row->category ?? ''), + $this->csvText($row->production_status ?? ''), + $row->minimum_price ?? null, + $row->price_limit ?? null, + $row->purchase_price ?? null, + $row->shipping_fee ?? null, + $row->jingxi_limit_price ?? null, + $this->csvText($row->approval_status ?? ''), + $this->csvText($row->approval_result ?? ''), + $this->csvText($row->workflow_title ?? ''), + $this->csvText($row->completed_at ?? ''), + (int) ($row->is_overridden ?? 0) === 1 ? '是' : '否', + $this->csvText($row->override_updated_at ?? ''), + ]); + } + + fclose($csv); + }, '商品价格_' . now()->format('Ymd_His') . '.csv', [ + 'Content-Type' => 'text/csv; charset=UTF-8', + 'Cache-Control' => 'private, no-store', + ]); + } + + public function import(Request $request): RedirectResponse + { + $request->validate([ + 'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240'], + ]); + + $result = $this->importer->import( + $request->file('import'), + (int) (company_id() ?: 1), + user_id() ? (int) user_id() : null, + ); + + return $this->redirectToPage($request)->with( + 'success', + "导入完成:新增维护 {$result['created']} 条,更新维护 {$result['updated']} 条。", + ); + } + + public function update(Request $request, string $upstreamKey): RedirectResponse + { + $values = $request->validate( + [ + 'barcode' => ['required', 'string', 'max:191'], + 'spu' => ['nullable', 'string', 'max:191'], + 'product_name' => ['nullable', 'string', 'max:5000'], + 'category' => ['nullable', 'string', 'max:255'], + 'production_status' => ['nullable', 'string', 'max:120'], + 'minimum_price' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'], + 'price_limit' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'], + 'purchase_price' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'], + 'shipping_fee' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'], + 'jingxi_limit_price' => ['nullable', 'numeric', 'decimal:0,4', 'min:0', 'max:9999999999999999.9999'], + ], + [ + 'minimum_price.decimal' => '最低售价最多支持 4 位小数。', + 'price_limit.decimal' => '计算限价最多支持 4 位小数。', + 'purchase_price.decimal' => '采购价最多支持 4 位小数。', + 'shipping_fee.decimal' => '运费最多支持 4 位小数。', + 'jingxi_limit_price.decimal' => '京喜限价最多支持 4 位小数。', + ], + ); + + try { + $this->repository->applyOverrides( + [$upstreamKey => $values], + (int) (company_id() ?: 1), + user_id() ? (int) user_id() : null, + 'manual', + ); + } catch (RuntimeException $exception) { + if (str_starts_with($exception->getMessage(), 'product_price_record_not_found:')) { + abort(404, '商品价格记录不存在。'); + } + + if ($exception->getMessage() === 'product_price_maintenance_schema_missing') { + throw ValidationException::withMessages([ + 'barcode' => '商品价格维护功能尚未完成初始化,请稍后重试。', + ]); + } + + throw $exception; + } + + return $this->redirectToPage($request)->with('success', '商品价格已保存。'); + } + + /** @return array{barcode:string,completed_from:string,completed_to:string} */ + private function filters(Request $request): array + { + return [ + 'barcode' => mb_substr(trim((string) $request->query('barcode', '')), 0, 191), + 'completed_from' => $this->date((string) $request->query('completed_from', '')), + 'completed_to' => $this->date((string) $request->query('completed_to', '')), + ]; + } + + private function redirectToPage(Request $request): RedirectResponse + { + $filters = $this->filters($request); + $query = array_filter([ + ...$filters, + 'page' => max((int) $request->query('page', 1), 1), + ], static fn (mixed $value): bool => $value !== ''); + $url = route('business.pages.show', [ + 'company_id' => company_id() ?: 1, + 'section' => 'supplier-payables', + 'page' => 'product-prices', + ]); + + return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query))); + } + + private function date(string $value): string + { + $value = trim($value); + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) { + return ''; + } + + [$year, $month, $day] = array_map('intval', explode('-', $value)); + + return checkdate($month, $day, $year) ? $value : ''; + } + + private function csvText(mixed $value): string + { + $value = (string) $value; + + return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value; + } +} diff --git a/database/migrations/2026_09_16_000000_create_product_price_override_tables.php b/database/migrations/2026_09_16_000000_create_product_price_override_tables.php new file mode 100644 index 00000000..1013a8dc --- /dev/null +++ b/database/migrations/2026_09_16_000000_create_product_price_override_tables.php @@ -0,0 +1,78 @@ +environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + + if (! $schema->hasTable('product_price_overrides')) { + $schema->create('product_price_overrides', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->char('upstream_key', 64); + $table->string('barcode', 191); + $table->string('spu', 191)->nullable(); + $table->text('product_name')->nullable(); + $table->string('category', 255)->nullable(); + $table->string('production_status', 120)->nullable(); + $table->decimal('minimum_price', 20, 4)->nullable(); + $table->decimal('price_limit', 20, 4)->nullable(); + $table->decimal('purchase_price', 20, 4)->nullable(); + $table->decimal('shipping_fee', 20, 4)->nullable(); + $table->decimal('jingxi_limit_price', 20, 4)->nullable(); + $table->string('source', 24); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + + $table->unique(['company_id', 'upstream_key'], 'product_price_overrides_company_key_unique'); + $table->index(['company_id', 'barcode'], 'product_price_overrides_company_barcode'); + }); + } + + if (! $schema->hasTable('product_price_override_events')) { + $schema->create('product_price_override_events', function (Blueprint $table): void { + $table->bigIncrements('id'); + $table->unsignedInteger('company_id'); + $table->char('upstream_key', 64); + $table->string('source', 24); + $table->json('before_values')->nullable(); + $table->json('after_values'); + $table->unsignedInteger('user_id')->nullable(); + $table->timestamp('created_at')->useCurrent(); + + $table->index( + ['company_id', 'upstream_key', 'created_at'], + 'product_price_override_events_lookup' + ); + }); + } + } + + public function down(): void + { + if (app()->environment('testing') + || (string) config('system_database.mode', 'bootstrap') === 'bootstrap') { + return; + } + + $schema = Schema::connection($this->connection()); + $schema->dropIfExists('product_price_override_events'); + $schema->dropIfExists('product_price_overrides'); + } +}; diff --git a/modules/EcommerceFinance/Repositories/ProductPriceMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductPriceMirrorRepository.php index 39bda7ee..7b95b18a 100644 --- a/modules/EcommerceFinance/Repositories/ProductPriceMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductPriceMirrorRepository.php @@ -4,14 +4,30 @@ namespace Modules\EcommerceFinance\Repositories; use Illuminate\Database\ConnectionInterface; use Illuminate\Database\DatabaseManager; +use Illuminate\Database\Query\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Carbon; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; +use RuntimeException; class ProductPriceMirrorRepository { public const DATASET_KEY = 'supplier-payables.product-prices'; + public const EDITABLE_FIELDS = [ + 'barcode', + 'spu', + 'product_name', + 'category', + 'production_status', + 'minimum_price', + 'price_limit', + 'purchase_price', + 'shipping_fee', + 'jingxi_limit_price', + ]; + private readonly ConnectionInterface $connection; public function __construct(DatabaseManager $database) @@ -36,6 +52,14 @@ class ProductPriceMirrorRepository return is_array($state) && ($state['initial_completed_at'] ?? null) !== null; } + public function maintenanceReady(): bool + { + $schema = Schema::connection($this->connection->getName()); + + return $schema->hasTable('product_price_overrides') + && $schema->hasTable('product_price_override_events'); + } + /** @return array|null */ public function state(): ?array { @@ -46,34 +70,113 @@ class ProductPriceMirrorRepository return $row === null ? null : (array) $row; } - public function paginate(array $filters, int $page, int $limit): LengthAwarePaginator + public function paginate(array $filters, int $page, int $limit, ?int $companyId = null): LengthAwarePaginator { $page = max($page, 1); $limit = min(max($limit, 1), 100); - $query = $this->connection->table('product_price_mirror_rows'); + [$query, $withOverrides] = $this->effectiveQuery($companyId ?? (int) (company_id() ?: 1)); + $this->applyFilters($query, $filters, $withOverrides); + $this->applySelect($query, $withOverrides); - if (($filters['barcode'] ?? '') !== '') { - $query->whereRaw("barcode LIKE ? ESCAPE '!'", [$this->contains((string) $filters['barcode'])]); + return $this->applyOrder($query, $withOverrides) + ->paginate($limit, ['*'], 'page', $page); + } + + public function exportRows(array $filters, ?int $companyId = null): iterable + { + [$query, $withOverrides] = $this->effectiveQuery($companyId ?? (int) (company_id() ?: 1)); + $this->applyFilters($query, $filters, $withOverrides); + $this->applySelect($query, $withOverrides); + + return $this->applyOrder($query, $withOverrides)->cursor(); + } + + /** + * @param array> $records keyed by upstream key + * @return array{created:int,updated:int} + */ + public function applyOverrides( + array $records, + int $companyId, + ?int $userId, + string $source, + ): array { + if (! $this->maintenanceReady()) { + throw new RuntimeException('product_price_maintenance_schema_missing'); } - if (($filters['completed_from'] ?? '') !== '') { - $query->where('completed_at', '>=', (string) $filters['completed_from'] . ' 00:00:00'); + if ($records === []) { + return ['created' => 0, 'updated' => 0]; } - if (($filters['completed_to'] ?? '') !== '') { - $query->where('completed_at', '<', Carbon::parse((string) $filters['completed_to'])->addDay()->startOfDay()); - } + $keys = array_values(array_unique(array_keys($records))); - return $query - ->orderByDesc('completed_at') - ->orderByDesc('business_id') - ->orderBy('barcode') - ->paginate($limit, [ - 'barcode', 'spu', 'product_name', 'category', 'production_status', - 'minimum_price', 'price_limit', 'purchase_price', 'shipping_fee', - 'jingxi_limit_price', 'approval_status', 'approval_result', - 'workflow_title', 'completed_at', - ], 'page', $page); + return $this->connection->transaction(function () use ( + $records, + $keys, + $companyId, + $userId, + $source, + ): array { + [$effective, $withOverrides] = $this->effectiveQuery($companyId); + $this->applySelect($effective, $withOverrides); + $current = $effective->whereIn('r.upstream_key', $keys)->get()->keyBy('upstream_key'); + + if ($current->count() !== count($keys)) { + $missing = collect($keys)->first(fn (string $key): bool => ! $current->has($key)); + throw new RuntimeException('product_price_record_not_found:' . $missing); + } + + $existing = $this->connection->table('product_price_overrides') + ->where('company_id', $companyId) + ->whereIn('upstream_key', $keys) + ->pluck('upstream_key') + ->flip(); + $created = 0; + $updated = 0; + $now = now(); + + foreach ($records as $upstreamKey => $changes) { + $row = (array) $current->get($upstreamKey); + $before = $this->editableValues($row); + $provided = array_intersect_key($changes, array_flip(self::EDITABLE_FIELDS)); + $after = array_replace($before, $provided); + + if (trim((string) ($after['barcode'] ?? '')) === '') { + throw new RuntimeException('product_price_barcode_required:' . $upstreamKey); + } + + $identity = ['company_id' => $companyId, 'upstream_key' => $upstreamKey]; + $values = $after + [ + 'source' => $source, + 'updated_by' => $userId, + 'updated_at' => $now, + ]; + + if ($existing->has($upstreamKey)) { + $this->connection->table('product_price_overrides')->where($identity)->update($values); + $updated++; + } else { + $this->connection->table('product_price_overrides')->insert($identity + $values + [ + 'created_by' => $userId, + 'created_at' => $now, + ]); + $created++; + } + + $this->connection->table('product_price_override_events')->insert([ + 'company_id' => $companyId, + 'upstream_key' => $upstreamKey, + 'source' => $source, + 'before_values' => json_encode($before, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + 'after_values' => json_encode($after, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION), + 'user_id' => $userId, + 'created_at' => $now, + ]); + } + + return compact('created', 'updated'); + }); } public function startRun(string $mode, Carbon $since, Carbon $until, bool $resume): array @@ -213,4 +316,91 @@ class ProductPriceMirrorRepository { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; } + + /** @return array{0:Builder,1:bool} */ + private function effectiveQuery(int $companyId): array + { + $query = $this->connection->table('product_price_mirror_rows as r'); + $withOverrides = $this->maintenanceReady(); + + if ($withOverrides) { + $query->leftJoin('product_price_overrides as o', function ($join) use ($companyId): void { + $join->on('o.upstream_key', '=', 'r.upstream_key') + ->where('o.company_id', '=', $companyId); + }); + } + + return [$query, $withOverrides]; + } + + private function applyFilters(Builder $query, array $filters, bool $withOverrides): void + { + if (($filters['barcode'] ?? '') !== '') { + $query->whereRaw( + $this->effectiveColumn('barcode', $withOverrides) . " LIKE ? ESCAPE '!'", + [$this->contains((string) $filters['barcode'])], + ); + } + + if (($filters['completed_from'] ?? '') !== '') { + $query->where('r.completed_at', '>=', (string) $filters['completed_from'] . ' 00:00:00'); + } + + if (($filters['completed_to'] ?? '') !== '') { + $query->where('r.completed_at', '<', Carbon::parse((string) $filters['completed_to'])->addDay()->startOfDay()); + } + } + + private function applySelect(Builder $query, bool $withOverrides): void + { + $query->select([ + 'r.id', + 'r.upstream_key', + 'r.business_id', + 'r.approval_status', + 'r.approval_result', + 'r.workflow_title', + 'r.completed_at', + ]); + + foreach (self::EDITABLE_FIELDS as $field) { + $query->addSelect(DB::raw($this->effectiveColumn($field, $withOverrides) . ' AS ' . $field)); + } + + if ($withOverrides) { + $query->addSelect([ + DB::raw('CASE WHEN o.id IS NULL THEN 0 ELSE 1 END AS is_overridden'), + 'o.updated_at as override_updated_at', + ]); + } else { + $query->addSelect([ + DB::raw('0 AS is_overridden'), + DB::raw('NULL AS override_updated_at'), + ]); + } + } + + private function applyOrder(Builder $query, bool $withOverrides): Builder + { + return $query + ->orderByDesc('r.completed_at') + ->orderByDesc('r.business_id') + ->orderByRaw($this->effectiveColumn('barcode', $withOverrides) . ' ASC'); + } + + private function effectiveColumn(string $field, bool $withOverrides): string + { + return $withOverrides + ? "CASE WHEN o.id IS NULL THEN r.{$field} ELSE o.{$field} END" + : "r.{$field}"; + } + + /** @return array */ + private function editableValues(array $row): array + { + return array_combine( + self::EDITABLE_FIELDS, + array_map(static fn (string $field): mixed => $row[$field] ?? null, self::EDITABLE_FIELDS), + ); + } } diff --git a/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php b/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php new file mode 100644 index 00000000..48394b17 --- /dev/null +++ b/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php @@ -0,0 +1,222 @@ + */ + private const HEADERS = [ + '记录标识' => 'upstream_key', + '商品编码' => 'barcode', + 'SPU' => 'spu', + '商品名称' => 'product_name', + '类目' => 'category', + '生产状态' => 'production_status', + '最低售价' => 'minimum_price', + '计算限价' => 'price_limit', + '采购价' => 'purchase_price', + '运费' => 'shipping_fee', + '京喜限价' => 'jingxi_limit_price', + ]; + + /** @var array */ + private const TEXT_LIMITS = [ + 'barcode' => 191, + 'spu' => 191, + 'product_name' => 5000, + 'category' => 255, + 'production_status' => 120, + ]; + + public function __construct(private readonly ProductPriceMirrorRepository $repository) + { + } + + /** @return array{created:int,updated:int,rows:int} */ + public function import(UploadedFile $file, int $companyId, ?int $userId): array + { + try { + $spreadsheet = IOFactory::load($file->getRealPath()); + [$rows, $headerRow, $columns] = $this->bestMatchingSheet($spreadsheet); + } catch (Throwable) { + throw ValidationException::withMessages([ + 'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。', + ]); + } finally { + if (isset($spreadsheet)) { + $spreadsheet->disconnectWorksheets(); + } + } + + if ($rows === null || ! in_array('upstream_key', $columns, true)) { + throw ValidationException::withMessages([ + 'import' => '未找到“记录标识”列,请先导出当前数据,再使用导出的表格进行修改。', + ]); + } + + if (count(array_intersect($columns, ProductPriceMirrorRepository::EDITABLE_FIELDS)) === 0) { + throw ValidationException::withMessages([ + 'import' => '表格中没有可导入的商品价格字段。', + ]); + } + + $records = []; + + foreach (array_slice($rows, $headerRow + 1, null, true) as $rowIndex => $row) { + $line = $rowIndex + 1; + $upstreamKey = ''; + $changes = []; + + foreach ($columns as $columnIndex => $field) { + $value = $row[$columnIndex] ?? null; + + if ($field === 'upstream_key') { + $upstreamKey = strtolower(trim((string) $value)); + continue; + } + + $changes[$field] = $this->normalizeValue($field, $value, $line); + } + + if ($upstreamKey === '' && collect($changes)->every( + static fn (mixed $value): bool => $value === null || $value === '' + )) { + continue; + } + + if (preg_match('/^[a-f0-9]{64}$/', $upstreamKey) !== 1) { + throw ValidationException::withMessages([ + 'import' => "第 {$line} 行“记录标识”无效,请勿修改导出文件中的该列。", + ]); + } + + if (array_key_exists($upstreamKey, $records)) { + throw ValidationException::withMessages([ + 'import' => "第 {$line} 行“记录标识”重复。", + ]); + } + + if (array_key_exists('barcode', $changes) && trim((string) $changes['barcode']) === '') { + throw ValidationException::withMessages([ + 'import' => "第 {$line} 行“商品编码”不能为空。", + ]); + } + + if (collect($changes)->every(static fn (mixed $value): bool => $value === null || $value === '')) { + continue; + } + + $records[$upstreamKey] = $changes; + + if (count($records) > 10000) { + throw ValidationException::withMessages([ + 'import' => '单次最多导入 10,000 条商品价格记录,请拆分文件后重试。', + ]); + } + } + + if ($records === []) { + throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']); + } + + try { + $result = $this->repository->applyOverrides($records, $companyId, $userId, 'import'); + } catch (RuntimeException $exception) { + if (str_starts_with($exception->getMessage(), 'product_price_record_not_found:')) { + throw ValidationException::withMessages([ + 'import' => '导入文件包含当前商品价格镜像中不存在的记录,请重新导出后再导入。', + ]); + } + + if ($exception->getMessage() === 'product_price_maintenance_schema_missing') { + throw ValidationException::withMessages([ + 'import' => '商品价格维护功能尚未完成初始化,请稍后重试。', + ]); + } + + throw $exception; + } + + return $result + ['rows' => count($records)]; + } + + private function bestMatchingSheet($spreadsheet): array + { + $lookup = []; + + foreach (self::HEADERS as $label => $field) { + $lookup[$this->normalizeHeader($label)] = $field; + $lookup[$this->normalizeHeader($field)] = $field; + } + + $best = [null, null, []]; + $bestScore = 0; + + foreach ($spreadsheet->getWorksheetIterator() as $worksheet) { + $rows = $worksheet->toArray(null, false, false, false); + + foreach (array_slice($rows, 0, 20, true) as $rowIndex => $row) { + $columns = []; + + foreach ($row as $columnIndex => $heading) { + $key = $this->normalizeHeader($heading); + + if ($key !== '' && isset($lookup[$key])) { + $columns[$columnIndex] = $lookup[$key]; + } + } + + if (count($columns) > $bestScore) { + $best = [$rows, $rowIndex, $columns]; + $bestScore = count($columns); + } + } + } + + return $best; + } + + private function normalizeHeader(mixed $value): string + { + return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value))); + } + + private function normalizeValue(string $field, mixed $value, int $line): mixed + { + if ($value === null || trim((string) $value) === '') { + return null; + } + + if (isset(self::TEXT_LIMITS[$field])) { + $text = trim((string) $value); + + if (mb_strlen($text) > self::TEXT_LIMITS[$field]) { + $label = array_search($field, self::HEADERS, true) ?: $field; + throw ValidationException::withMessages([ + 'import' => "第 {$line} 行“{$label}”内容过长。", + ]); + } + + return $text; + } + + $number = str_replace([',', ',', '¥', '¥'], '', trim((string) $value)); + + if (preg_match('/^(?:\d+(?:\.\d{1,4})?|\.\d{1,4})$/', $number) !== 1 + || bccomp($number, '9999999999999999.9999', 4) === 1) { + $label = array_search($field, self::HEADERS, true) ?: $field; + throw ValidationException::withMessages([ + 'import' => "第 {$line} 行“{$label}”必须是大于或等于 0、最多 4 位小数的有效金额。", + ]); + } + + return ltrim($number, '+'); + } +} diff --git a/resources/views/business/pages/supplier-payables/product-prices.blade.php b/resources/views/business/pages/supplier-payables/product-prices.blade.php index 84abf3e2..4985d1f1 100644 --- a/resources/views/business/pages/supplier-payables/product-prices.blade.php +++ b/resources/views/business/pages/supplier-payables/product-prices.blade.php @@ -7,7 +7,7 @@ @@ -262,20 +442,58 @@
-
+
+ + + + 导出 + - refresh + 重置
+
+ @csrf + +
+ + @if ($errors->any() && !$errors->has('import')) + + @endif + @if ($productPriceError) + +
+ + diff --git a/routes/admin.php b/routes/admin.php index 83f60d93..2b11b814 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Business\CourierQuoteSignatures; use App\Http\Controllers\Business\CourierAgreementSignatures; use App\Http\Controllers\Business\CourierSurchargeQuotes; use App\Http\Controllers\Business\CourierSurchargeTypes; +use App\Http\Controllers\Business\ProductPrices; use Illuminate\Support\Facades\Route; use Modules\EcommerceFinance\Http\Controllers\AccountBills; use Modules\EcommerceFinance\Http\Controllers\AlipayBills; @@ -127,6 +128,17 @@ Route::get('workspace/product-inventory/online-daifa-price/export', 'Business\Pa ->middleware('business.page:product-inventory,online-daifa-price') ->name('business.product-inventory.online-daifa-price.export'); +Route::prefix('workspace/supplier-payables/product-prices') + ->name('business.supplier-payables.product-prices.') + ->middleware('business.page:supplier-payables,product-prices') + ->group(function () { + Route::get('export', [ProductPrices::class, 'export'])->name('export'); + Route::post('import', [ProductPrices::class, 'import'])->middleware('import')->name('import'); + Route::patch('{upstreamKey}', [ProductPrices::class, 'update']) + ->where('upstreamKey', '[a-f0-9]{64}') + ->name('update'); + }); + Route::get('workspace/refunds/non-jingxi-refund-data/export', 'Business\Pages@exportNonJingxiRefunds') ->middleware('business.page:refunds,non-jingxi-refund-data') ->name('business.refunds.non-jingxi-refund-data.export'); diff --git a/tests/Feature/Business/ProductPriceActionsTest.php b/tests/Feature/Business/ProductPriceActionsTest.php new file mode 100644 index 00000000..7836248e --- /dev/null +++ b/tests/Feature/Business/ProductPriceActionsTest.php @@ -0,0 +1,164 @@ +user->attachPermission( + Permission::where('name', 'read-admin-panel')->firstOrFail() + ); + } + + public function test_export_downloads_all_rows_for_current_filters(): void + { + $this->grantAdminPermission(); + $repository = \Mockery::mock(ProductPriceMirrorRepository::class); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('initialized')->once()->andReturnTrue(); + $repository->shouldReceive('exportRows') + ->once() + ->withArgs(function (array $filters, int $companyId): bool { + return $companyId === (int) $this->company->id + && $filters === [ + 'barcode' => 'SKU-001', + 'completed_from' => '2026-08-01', + 'completed_to' => '2026-08-31', + ]; + }) + ->andReturn([(object) [ + 'upstream_key' => hash('sha256', 'export-row'), + 'business_id' => 'B-001', + 'barcode' => 'SKU-001', + 'spu' => 'SPU-001', + 'product_name' => '导出商品', + 'category' => '家具', + 'production_status' => '销售', + 'minimum_price' => 120, + 'price_limit' => 118, + 'purchase_price' => 80, + 'shipping_fee' => 10, + 'jingxi_limit_price' => 100, + 'approval_status' => '已完成', + 'approval_result' => '已同意', + 'workflow_title' => '价格审批', + 'completed_at' => '2026-08-15 10:00:00', + 'is_overridden' => 1, + 'override_updated_at' => '2026-09-16 09:00:00', + ]]); + $this->app->instance(ProductPriceMirrorRepository::class, $repository); + + $response = $this->loginAs()->get(route('business.supplier-payables.product-prices.export', [ + 'barcode' => 'SKU-001', + 'completed_from' => '2026-08-01', + 'completed_to' => '2026-08-31', + ])); + + $response->assertOk()->assertDownload(); + $content = $response->streamedContent(); + $this->assertStringContainsString('记录标识', $content); + $this->assertStringContainsString('SKU-001', $content); + $this->assertStringContainsString('导出商品', $content); + $this->assertStringContainsString('人工维护', $content); + } + + public function test_update_saves_manual_override_and_returns_to_current_filter(): void + { + $this->grantAdminPermission(); + $key = hash('sha256', 'update-row'); + $repository = \Mockery::mock(ProductPriceMirrorRepository::class); + $repository->shouldReceive('applyOverrides') + ->once() + ->withArgs(function (array $records, int $companyId, ?int $userId, string $source) use ($key): bool { + return $companyId === (int) $this->company->id + && $userId === (int) $this->user->id + && $source === 'manual' + && $records[$key]['barcode'] === 'SKU-EDIT' + && (float) $records[$key]['purchase_price'] === 82.5; + }) + ->andReturn(['created' => 1, 'updated' => 0]); + $this->app->instance(ProductPriceMirrorRepository::class, $repository); + + $response = $this->loginAs()->patch( + route('business.supplier-payables.product-prices.update', ['upstreamKey' => $key]) + . '?barcode=SKU&page=2', + [ + 'barcode' => 'SKU-EDIT', + 'spu' => 'SPU-EDIT', + 'product_name' => '编辑商品', + 'purchase_price' => '82.50', + ], + ); + + $response + ->assertRedirect(route('business.pages.show', [ + 'section' => 'supplier-payables', + 'page' => 'product-prices', + ]) . '?barcode=SKU&page=2') + ->assertSessionHas('success', '商品价格已保存。'); + } + + public function test_update_rejects_prices_with_more_than_four_decimal_places(): void + { + $this->grantAdminPermission(); + $key = hash('sha256', 'invalid-update-price'); + $repository = \Mockery::mock(ProductPriceMirrorRepository::class); + $repository->shouldNotReceive('applyOverrides'); + $this->app->instance(ProductPriceMirrorRepository::class, $repository); + + try { + $this->loginAs()->patch( + route('business.supplier-payables.product-prices.update', ['upstreamKey' => $key]), + [ + 'barcode' => 'SKU-EDIT', + 'purchase_price' => '82.12345', + ], + ); + $this->fail('超过 4 位小数的价格不应通过编辑。'); + } catch (ValidationException $exception) { + $this->assertSame( + '采购价最多支持 4 位小数。', + $exception->errors()['purchase_price'][0], + ); + } + } + + public function test_import_accepts_workbook_and_reports_created_and_updated_counts(): void + { + $this->grantAdminPermission(); + $importer = \Mockery::mock(ProductPriceWorkbookImporter::class); + $importer->shouldReceive('import') + ->once() + ->withArgs(function (UploadedFile $file, int $companyId, ?int $userId): bool { + return $file->getClientOriginalName() === '商品价格.csv' + && $companyId === (int) $this->company->id + && $userId === (int) $this->user->id; + }) + ->andReturn(['created' => 2, 'updated' => 3, 'rows' => 5]); + $this->app->instance(ProductPriceWorkbookImporter::class, $importer); + $file = UploadedFile::fake()->createWithContent( + '商品价格.csv', + "记录标识,商品编码,采购价\n" . hash('sha256', 'import-row') . ",SKU-1,80\n", + ); + + $response = $this->loginAs()->post( + route('business.supplier-payables.product-prices.import') . '?barcode=SKU-1', + ['import' => $file], + ); + + $response + ->assertRedirect(route('business.pages.show', [ + 'section' => 'supplier-payables', + 'page' => 'product-prices', + ]) . '?barcode=SKU-1&page=1') + ->assertSessionHas('success', '导入完成:新增维护 2 条,更新维护 3 条。'); + } +} diff --git a/tests/Feature/Business/ProductPriceMirrorRepositoryTest.php b/tests/Feature/Business/ProductPriceMirrorRepositoryTest.php index 615f733f..fca0fc63 100644 --- a/tests/Feature/Business/ProductPriceMirrorRepositoryTest.php +++ b/tests/Feature/Business/ProductPriceMirrorRepositoryTest.php @@ -3,8 +3,13 @@ namespace Tests\Feature\Business; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\DB; +use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository; +use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter; +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use Tests\TestCase; class ProductPriceMirrorRepositoryTest extends TestCase @@ -64,6 +69,36 @@ class ProductPriceMirrorRepositoryTest extends TestCase $table->dateTime('synced_at'); $table->timestamps(); }); + $schema->create('product_price_overrides', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('company_id'); + $table->string('upstream_key'); + $table->string('barcode'); + $table->string('spu')->nullable(); + $table->text('product_name')->nullable(); + $table->string('category')->nullable(); + $table->string('production_status')->nullable(); + $table->decimal('minimum_price')->nullable(); + $table->decimal('price_limit')->nullable(); + $table->decimal('purchase_price')->nullable(); + $table->decimal('shipping_fee')->nullable(); + $table->decimal('jingxi_limit_price')->nullable(); + $table->string('source'); + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->timestamps(); + $table->unique(['company_id', 'upstream_key']); + }); + $schema->create('product_price_override_events', function (Blueprint $table): void { + $table->id(); + $table->unsignedInteger('company_id'); + $table->string('upstream_key'); + $table->string('source'); + $table->json('before_values')->nullable(); + $table->json('after_values'); + $table->unsignedInteger('user_id')->nullable(); + $table->timestamp('created_at')->nullable(); + }); } public function test_page_two_and_literal_wildcard_filter_are_read_from_shared_mirror(): void @@ -125,4 +160,139 @@ class ProductPriceMirrorRepositoryTest extends TestCase $this->assertSame('2026-08-14T11:00:00.1234567', $state['cursor_completed_at']); $this->assertSame(2, (int) $state['last_upserted_rows']); } + + public function test_company_override_changes_effective_values_without_changing_mirror_row(): void + { + $key = hash('sha256', 'editable-price'); + DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([ + 'upstream_key' => $key, + 'business_id' => 'B100', + 'barcode' => 'SKU-OLD', + 'product_name' => '原商品', + 'purchase_price' => 91.11, + 'completed_at' => '2026-08-14 10:00:00', + 'row_hash' => hash('sha256', 'row'), + 'synced_at' => '2026-08-14 10:00:00', + ]); + + $repository = app(ProductPriceMirrorRepository::class); + $result = $repository->applyOverrides([$key => [ + 'barcode' => 'SKU-NEW', + 'product_name' => '维护后的商品', + 'purchase_price' => 88.5, + ]], 1, 9, 'manual'); + + $this->assertSame(['created' => 1, 'updated' => 0], $result); + $row = $repository->paginate(['barcode' => 'SKU-NEW'], 1, 25, 1)->items()[0]; + $otherCompanyRow = $repository->paginate([], 1, 25, 2)->items()[0]; + + $this->assertSame('SKU-NEW', $row->barcode); + $this->assertSame('维护后的商品', $row->product_name); + $this->assertSame(88.5, (float) $row->purchase_price); + $this->assertSame(1, (int) $row->is_overridden); + $this->assertSame('SKU-OLD', $otherCompanyRow->barcode); + $this->assertSame('SKU-OLD', DB::connection('business_mirror') + ->table('product_price_mirror_rows')->value('barcode')); + $this->assertSame(1, DB::connection('business_mirror') + ->table('product_price_override_events')->count()); + } + + public function test_exported_csv_can_be_imported_as_an_atomic_price_override(): void + { + $key = hash('sha256', 'import-price'); + DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([ + 'upstream_key' => $key, + 'business_id' => 'B200', + 'barcode' => 'SKU-IMPORT', + 'product_name' => '导入前商品', + 'purchase_price' => 100, + 'completed_at' => '2026-08-15 10:00:00', + 'row_hash' => hash('sha256', 'import-row'), + 'synced_at' => '2026-08-15 10:00:00', + ]); + $csv = "记录标识,商品编码,商品名称,采购价\n{$key},SKU-IMPORT,导入后商品,86.25\n"; + $file = UploadedFile::fake()->createWithContent('商品价格.csv', $csv); + + $result = app(ProductPriceWorkbookImporter::class)->import($file, 1, 9); + + $this->assertSame(['created' => 1, 'updated' => 0, 'rows' => 1], $result); + $override = DB::connection('business_mirror')->table('product_price_overrides')->first(); + $this->assertSame('导入后商品', $override->product_name); + $this->assertSame(86.25, (float) $override->purchase_price); + $this->assertSame('import', $override->source); + } + + public function test_xlsx_workbook_can_be_imported_as_a_price_override(): void + { + $key = hash('sha256', 'xlsx-import-price'); + DB::connection('business_mirror')->table('product_price_mirror_rows')->insert([ + 'upstream_key' => $key, + 'business_id' => 'B300', + 'barcode' => 'SKU-XLSX', + 'product_name' => 'Excel 导入前商品', + 'purchase_price' => 100, + 'completed_at' => '2026-08-16 10:00:00', + 'row_hash' => hash('sha256', 'xlsx-import-row'), + 'synced_at' => '2026-08-16 10:00:00', + ]); + $spreadsheet = new Spreadsheet(); + $spreadsheet->getActiveSheet()->fromArray([ + ['记录标识', '商品编码', '商品名称', '采购价'], + [$key, 'SKU-XLSX', 'Excel 导入后商品', 86.25], + ]); + $path = tempnam(sys_get_temp_dir(), 'product-price-xlsx-'); + + try { + (new Xlsx($spreadsheet))->save($path); + $file = new UploadedFile( + $path, + '商品价格.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + null, + true, + ); + + $result = app(ProductPriceWorkbookImporter::class)->import($file, 1, 9); + } finally { + $spreadsheet->disconnectWorksheets(); + @unlink($path); + } + + $this->assertSame(['created' => 1, 'updated' => 0, 'rows' => 1], $result); + $override = DB::connection('business_mirror')->table('product_price_overrides')->first(); + $this->assertSame('Excel 导入后商品', $override->product_name); + $this->assertSame(86.25, (float) $override->purchase_price); + } + + public function test_import_rejects_damaged_workbooks_with_a_validation_message(): void + { + $file = UploadedFile::fake()->createWithContent('商品价格.xlsx', "PK\x03\x04broken workbook"); + + try { + app(ProductPriceWorkbookImporter::class)->import($file, 1, 9); + $this->fail('损坏的工作簿不应通过导入。'); + } catch (ValidationException $exception) { + $this->assertSame( + '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。', + $exception->errors()['import'][0], + ); + } + } + + public function test_import_rejects_prices_with_more_than_four_decimal_places(): void + { + $key = hash('sha256', 'invalid-price-precision'); + $csv = "记录标识,商品编码,采购价\n{$key},SKU-PRECISION,86.25001\n"; + $file = UploadedFile::fake()->createWithContent('商品价格.csv', $csv); + + try { + app(ProductPriceWorkbookImporter::class)->import($file, 1, 9); + $this->fail('超过 4 位小数的价格不应通过导入。'); + } catch (ValidationException $exception) { + $this->assertSame( + '第 2 行“采购价”必须是大于或等于 0、最多 4 位小数的有效金额。', + $exception->errors()['import'][0], + ); + } + } } diff --git a/tests/Feature/Business/SupplierPayablePagesTest.php b/tests/Feature/Business/SupplierPayablePagesTest.php index 15cee50e..c0f42440 100644 --- a/tests/Feature/Business/SupplierPayablePagesTest.php +++ b/tests/Feature/Business/SupplierPayablePagesTest.php @@ -126,6 +126,7 @@ class SupplierPayablePagesTest extends FeatureTestCase ] && $page === 1 && $limit > 0; }) ->andReturn(new LengthAwarePaginator([(object) [ + 'upstream_key' => hash('sha256', 'product-price-row'), 'barcode' => 'YMJ005203N1176V01', 'spu' => 'YMJ0052', 'product_name' => '款式二 36/36/176 胡桃木色 BY-008', @@ -161,11 +162,17 @@ class SupplierPayablePagesTest extends FeatureTestCase ->assertSee('YMJ005203N1176V01') ->assertSee('152.80') ->assertSee('已同意') + ->assertSeeText('导入') + ->assertSeeText('导出') + ->assertSeeText('查询') + ->assertSeeText('重置') + ->assertSee('data-product-price-edit', false) + ->assertSeeText('编辑商品价格') ->assertDontSee('是否采购降本'); preg_match_all('/)/', $response->getContent(), $headers); - $this->assertCount(15, $headers[0]); + $this->assertCount(16, $headers[0]); } public function testProductPricesPageDoesNotTreatUninitializedMirrorAsEmptyData(): void