完善商品价格导入导出与编辑 #197
189
app/Http/Controllers/Business/ProductPrices.php
Normal file
189
app/Http/Controllers/Business/ProductPrices.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, '+');
|
||||
}
|
||||
}
|
||||
@ -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"> </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>
|
||||
|
||||
@ -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');
|
||||
|
||||
164
tests/Feature/Business/ProductPriceActionsTest.php
Normal file
164
tests/Feature/Business/ProductPriceActionsTest.php
Normal 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 条。');
|
||||
}
|
||||
}
|
||||
@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user