完善商品价格导入导出与编辑 #197

Merged
renshen merged 1 commits from feature/renshen-product-price-actions into main 2026-09-16 18:20:26 +08:00
9 changed files with 1451 additions and 25 deletions

View File

@ -0,0 +1,189 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ProductPrices extends Controller
{
public function __construct(
private readonly ProductPriceMirrorRepository $repository,
private readonly ProductPriceWorkbookImporter $importer,
) {
$this->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;
}
}

View File

@ -0,0 +1,78 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
private function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public function up(): void
{
if (app()->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');
}
};

View File

@ -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<string,mixed>|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<string,array<string,mixed>> $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<string,mixed> */
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),
);
}
}

View File

@ -0,0 +1,222 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
use Throwable;
class ProductPriceWorkbookImporter
{
/** @var array<string,string> */
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<string,int> */
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, '+');
}
}

View File

@ -7,7 +7,7 @@
<style>
.product-prices-filter-bar {
display: grid;
grid-template-columns: minmax(220px, 1.4fr) repeat(2, minmax(160px, 1fr)) auto;
grid-template-columns: minmax(220px, 1.4fr) repeat(2, minmax(160px, 1fr)) minmax(390px, auto);
align-items: flex-end;
gap: 16px;
padding: 16px;
@ -87,6 +87,53 @@
border-color: var(--vip-content-accent-dark, #856632);
}
.product-price-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.product-price-actions .material-icons-outlined,
.product-price-edit-button .material-icons-outlined {
font-size: 16px;
}
.product-price-import-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
}
.product-price-edit-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 8px;
background: #ffffff;
color: var(--vip-content-accent-dark, #856632);
cursor: pointer;
}
.product-price-edit-button:hover {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-soft, #F7F0DE);
}
.product-price-maintained {
display: inline-block;
margin-left: 6px;
color: var(--vip-success, #53793E);
font-size: 0.75rem;
white-space: nowrap;
}
.approval-tag {
display: inline-block;
padding: 2px 8px;
@ -231,6 +278,118 @@
color: #ffffff;
}
.product-price-modal[hidden] {
display: none;
}
.product-price-modal {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.product-price-modal__backdrop {
position: absolute;
inset: 0;
background: rgba(5, 5, 6, 0.48);
}
.product-price-modal__panel {
position: relative;
width: min(760px, 100%);
max-height: calc(100vh - 48px);
overflow-y: auto;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 12px;
background: #ffffff;
box-shadow: 0 4px 8px rgba(23, 23, 23, 0.10);
}
.product-price-modal__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
border-bottom: 1px solid var(--vip-border, #D8D2C7);
}
.product-price-modal__title {
margin: 0;
color: #171717;
font-size: 0.875rem;
font-weight: 600;
}
.product-price-modal__close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: #595959;
cursor: pointer;
}
.product-price-modal__close:hover {
background: var(--vip-content-accent-soft, #F7F0DE);
color: #171717;
}
.product-price-modal__body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
padding: 16px;
}
.product-price-modal__field {
display: flex;
flex-direction: column;
gap: 4px;
}
.product-price-modal__field.is-wide {
grid-column: 1 / -1;
}
.product-price-modal__field label {
color: #595959;
font-size: 0.75rem;
font-weight: 500;
}
.product-price-modal__field input {
width: 100%;
height: 34px;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 8px;
padding: 0 10px;
color: #171717;
background: #ffffff;
font-size: 0.8125rem;
}
.product-price-modal__field input:focus {
outline: none;
border-color: var(--vip-content-accent-dark, #856632);
box-shadow: 0 0 0 3px rgba(133, 102, 50, 0.20);
}
.product-price-modal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 16px 16px;
}
@media (max-width: 1200px) {
.product-prices-filter-bar {
grid-template-columns: repeat(3, minmax(160px, 1fr));
@ -241,6 +400,27 @@
.product-prices-filter-bar {
grid-template-columns: 1fr;
}
.product-price-actions {
justify-content: flex-start;
}
.product-price-modal {
align-items: flex-start;
padding: 12px;
}
.product-price-modal__panel {
max-height: calc(100vh - 24px);
}
.product-price-modal__body {
grid-template-columns: 1fr;
}
.product-price-modal__field.is-wide {
grid-column: auto;
}
}
</style>
@ -262,20 +442,58 @@
</div>
<div class="filter-group">
<label class="filter-label">&nbsp;</label>
<div class="flex gap-2">
<div class="product-price-actions">
<label for="product-price-import" class="btn-filter btn-filter-secondary" role="button" tabindex="0">
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
导入
</label>
<a
href="{{ route('business.supplier-payables.product-prices.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}"
class="btn-filter btn-filter-secondary"
aria-label="导出当前筛选结果"
title="导出当前筛选结果"
>
<span class="material-icons-outlined" aria-hidden="true">download</span>
导出
</a>
<button type="submit" class="btn-filter btn-filter-primary">
<span class="material-icons-outlined text-base">search</span>
<span class="material-icons-outlined" aria-hidden="true">search</span>
查询
</button>
<a href="{{ route('business.pages.show', ['company_id' => company_id(), 'section' => $section['slug'], 'page' => $page['slug']]) }}"
class="btn-filter btn-filter-secondary">
<span class="material-icons-outlined text-base">refresh</span>
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
重置
</a>
</div>
</div>
</form>
<form
id="product-price-import-form"
method="POST"
action="{{ route('business.supplier-payables.product-prices.import', ['company_id' => company_id()]) }}{{ request()->getQueryString() ? '?' . request()->getQueryString() : '' }}"
enctype="multipart/form-data"
>
@csrf
<input
id="product-price-import"
class="product-price-import-input"
type="file"
name="import"
form="product-price-import-form"
accept=".xlsx,.xls,.csv"
required
>
</form>
@if ($errors->any() && !$errors->has('import'))
<div class="product-price-error" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
<span>{{ $errors->first() }}</span>
</div>
@endif
@if ($productPriceError)
<div class="product-price-error" role="alert">
<span class="material-icons-outlined" aria-hidden="true">error_outline</span>
@ -303,6 +521,7 @@
<th>审批结果</th>
<th>流程标题</th>
<th>流程完成时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@ -335,10 +554,39 @@
@endif
</td>
<td>{{ $item->completed_at ?? '-' }}</td>
<td>
@if (!empty($item->upstream_key))
<button
type="button"
class="product-price-edit-button"
aria-label="编辑 {{ $item->barcode ?? '商品价格' }}"
title="编辑"
data-product-price-edit
data-action="{{ route('business.supplier-payables.product-prices.update', ['company_id' => company_id(), 'upstreamKey' => $item->upstream_key]) }}{{ request()->getQueryString() ? '?' . request()->getQueryString() : '' }}"
data-barcode="{{ $item->barcode ?? '' }}"
data-spu="{{ $item->spu ?? '' }}"
data-product-name="{{ $item->product_name ?? '' }}"
data-category="{{ $item->category ?? '' }}"
data-production-status="{{ $item->production_status ?? '' }}"
data-minimum-price="{{ $item->minimum_price ?? '' }}"
data-price-limit="{{ $item->price_limit ?? '' }}"
data-purchase-price="{{ $item->purchase_price ?? '' }}"
data-shipping-fee="{{ $item->shipping_fee ?? '' }}"
data-jingxi-limit-price="{{ $item->jingxi_limit_price ?? '' }}"
>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
</button>
@if ((int) ($item->is_overridden ?? 0) === 1)
<span class="product-price-maintained">已维护</span>
@endif
@else
-
@endif
</td>
</tr>
@empty
<tr>
<td colspan="15" class="text-center py-12">
<td colspan="16" class="text-center py-12">
<span class="material-icons-outlined text-4xl mb-2 block">inventory_2</span>
@if ($productPriceError)
数据读取失败,请稍后重试。
@ -398,4 +646,150 @@
@endif
@endif
</div>
<div class="product-price-modal" data-product-price-modal hidden>
<div class="product-price-modal__backdrop" data-product-price-close aria-hidden="true"></div>
<section class="product-price-modal__panel" role="dialog" aria-modal="true" aria-labelledby="product-price-edit-title">
<header class="product-price-modal__header">
<h2 id="product-price-edit-title" class="product-price-modal__title">编辑商品价格</h2>
<button type="button" class="product-price-modal__close" data-product-price-close aria-label="关闭" title="关闭">
<span class="material-icons-outlined" aria-hidden="true">close</span>
</button>
</header>
<form method="POST" data-product-price-edit-form>
@csrf
@method('PATCH')
<div class="product-price-modal__body">
<div class="product-price-modal__field">
<label for="product-price-barcode">商品编码</label>
<input id="product-price-barcode" name="barcode" required maxlength="191">
</div>
<div class="product-price-modal__field">
<label for="product-price-spu">SPU</label>
<input id="product-price-spu" name="spu" maxlength="191">
</div>
<div class="product-price-modal__field is-wide">
<label for="product-price-name">商品名称</label>
<input id="product-price-name" name="product_name" maxlength="5000">
</div>
<div class="product-price-modal__field">
<label for="product-price-category">类目</label>
<input id="product-price-category" name="category" maxlength="255">
</div>
<div class="product-price-modal__field">
<label for="product-price-production-status">生产状态</label>
<input id="product-price-production-status" name="production_status" maxlength="120">
</div>
@foreach ([
'minimum_price' => '最低售价',
'price_limit' => '计算限价',
'purchase_price' => '采购价',
'shipping_fee' => '运费',
'jingxi_limit_price' => '京喜限价',
] as $field => $label)
<div class="product-price-modal__field">
<label for="product-price-{{ str_replace('_', '-', $field) }}">{{ $label }}</label>
<input
id="product-price-{{ str_replace('_', '-', $field) }}"
name="{{ $field }}"
type="number"
min="0"
step="0.0001"
inputmode="decimal"
>
</div>
@endforeach
</div>
<footer class="product-price-modal__footer">
<button type="button" class="btn-filter btn-filter-secondary" data-product-price-close>取消</button>
<button type="submit" class="btn-filter btn-filter-primary">
<span class="material-icons-outlined" aria-hidden="true">save</span>
保存
</button>
</footer>
</form>
</section>
</div>
</div>
<script>
(() => {
const importInput = document.getElementById('product-price-import');
const importForm = document.getElementById('product-price-import-form');
const importLabel = document.querySelector('label[for="product-price-import"]');
const modal = document.querySelector('[data-product-price-modal]');
const editForm = modal?.querySelector('[data-product-price-edit-form]');
let returnFocus = null;
importLabel?.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
importInput?.click();
}
});
importInput?.addEventListener('change', () => {
if (!importInput.files?.length) {
return;
}
if (window.confirm('确认导入并更新表格中的商品价格记录吗?')) {
importForm?.submit();
} else {
importInput.value = '';
}
});
const closeModal = () => {
if (!modal) {
return;
}
modal.hidden = true;
returnFocus?.focus();
};
document.querySelectorAll('[data-product-price-edit]').forEach((button) => {
button.addEventListener('click', () => {
if (!modal || !editForm) {
return;
}
returnFocus = button;
editForm.action = button.dataset.action || '';
const values = {
barcode: button.dataset.barcode,
spu: button.dataset.spu,
product_name: button.dataset.productName,
category: button.dataset.category,
production_status: button.dataset.productionStatus,
minimum_price: button.dataset.minimumPrice,
price_limit: button.dataset.priceLimit,
purchase_price: button.dataset.purchasePrice,
shipping_fee: button.dataset.shippingFee,
jingxi_limit_price: button.dataset.jingxiLimitPrice,
};
Object.entries(values).forEach(([name, value]) => {
const input = editForm.elements.namedItem(name);
if (input) {
input.value = value || '';
}
});
modal.hidden = false;
editForm.elements.namedItem('barcode')?.focus();
});
});
modal?.querySelectorAll('[data-product-price-close]').forEach((button) => {
button.addEventListener('click', closeModal);
});
document.addEventListener('keydown', (event) => {
if (event.key === 'Escape' && modal && !modal.hidden) {
closeModal();
}
});
})();
</script>

View File

@ -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');

View File

@ -0,0 +1,164 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter;
use Tests\Feature\FeatureTestCase;
class ProductPriceActionsTest extends FeatureTestCase
{
protected function grantAdminPermission(): void
{
$this->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 条。');
}
}

View File

@ -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],
);
}
}
}

View File

@ -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('/<th(?:\s|>)/', $response->getContent(), $headers);
$this->assertCount(15, $headers[0]);
$this->assertCount(16, $headers[0]);
}
public function testProductPricesPageDoesNotTreatUninitializedMirrorAsEmptyData(): void