Merge remote-tracking branch 'origin/main'

# Conflicts:
#	config/database.php
This commit is contained in:
limeng 2026-09-22 15:01:14 +08:00
commit fd0041fdb5
58 changed files with 3872 additions and 206 deletions

View File

@ -0,0 +1,72 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
use RuntimeException;
use Throwable;
class ImportPinduoduoSettlementBills extends Command
{
protected $signature = 'pinduoduo-bills:import-authoritative
{path : 聚胜算导出的拼多多完整账单 XLSX}
{--from= : 账期开始日期,格式 YYYY-MM-DD}
{--to= : 账期结束日期,格式 YYYY-MM-DD}
{--expected-count= : 完整账单权威笔数}
{--expected-income= : 完整账单权威收入}
{--expected-expense= : 完整账单权威支出}
{--batch=1000 : 分批写入行数}';
protected $description = '导入并核验聚胜算拼多多完整账单(保留全部账户类型)';
public function handle(PinduoduoSettlementBillImporter $importer): int
{
foreach (['from', 'to', 'expected-count', 'expected-income', 'expected-expense'] as $option) {
if (trim((string) $this->option($option)) === '') {
$this->error("缺少 --{$option},完整账单导入必须提供账期和三项权威控制数。");
return self::FAILURE;
}
}
try {
$result = $importer->import(
(string) $this->argument('path'),
(string) $this->option('from'),
(string) $this->option('to'),
(int) $this->option('expected-count'),
(string) $this->option('expected-income'),
(string) $this->option('expected-expense'),
(int) $this->option('batch'),
);
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
} catch (Throwable $exception) {
report($exception);
$this->error('拼多多完整货款账单导入失败,详细原因已记录到受保护日志。');
return self::FAILURE;
}
$this->table(['状态', '账单行数', '跳过行数', '店铺数', '账单项目数', '收入', '支出', '净额', '账期'], [[
$result['status'],
$result['row_count'],
$result['excluded_account_rows'],
$result['store_count'],
$result['bill_item_count'],
$result['income_total'],
$result['expense_total'],
$result['net_total'],
$result['period_start'] . ' 至 ' . $result['period_end'],
]]);
$this->line('账户类型:' . collect($result['account_type_counts'])
->map(fn (int $count, string $account): string => "{$account} {$count}")
->implode(';'));
$this->line('SHA-256: ' . $result['source_sha256']);
return self::SUCCESS;
}
}

View File

@ -54,6 +54,8 @@ use Modules\EcommerceFinance\Repositories\SmallPaymentWorkOrderMirrorRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\TmallTaobaoSpreadBillRepository;
use Modules\EcommerceFinance\Services\UpstreamQueryCacheFactory;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\View\PlatformPromotionAccountPageData;
use Modules\EcommerceFinance\View\OrderDataPageData;
use RuntimeException;
@ -95,6 +97,8 @@ class Pages extends Controller
private readonly DingPayableWorkflowMirrorRepository $dingPayableWorkflows,
private readonly DingOnlineDaifaPriceMirrorRepository $onlineDaifaPrices,
private readonly SupplierPayableReportRepository $supplierPayableReports,
private readonly SupplierPayableOverrideStore $supplierPayableOverrides,
private readonly SupplierPayableMaintenanceRegistry $supplierPayableMaintenance,
private readonly UpstreamQueryCacheFactory $upstreamCaches,
private readonly CourierSurchargeQuoteRepository $courierSurchargeQuotes,
private readonly CourierSurchargeTypeRepository $courierSurchargeTypes,
@ -2565,10 +2569,16 @@ class Pages extends Controller
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->boardDeductions($filters, $page, $limit),
);
$rows = $this->supplierPayableOverrides->decorate(
'board-deductions',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'boardDeductions' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page),
'boardDeductions' => $this->supplierPayablePaginator($rows, $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => $cache->responseHeaders(),
'boardDeductionError' => null,
];
@ -2576,6 +2586,7 @@ class Pages extends Controller
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => [],
'boardDeductionError' => $exception->getMessage(),
];
@ -2585,6 +2596,7 @@ class Pages extends Controller
return [
'boardDeductions' => $this->supplierPayablePaginator([], $limit, $page),
'boardDeductionFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('board-deductions'),
'boardDeductionStatus' => [],
'boardDeductionError' => '板材扣款数据暂时无法读取,请稍后重试。',
];
@ -2610,10 +2622,16 @@ class Pages extends Controller
['filters' => $filters, 'page' => $page, 'limit' => $limit],
fn () => $this->supplierPayableReports->supplierPayables($filters, $page, $limit),
);
$rows = $this->supplierPayableOverrides->decorate(
'supplier-payables-report',
(array) ($result['rows'] ?? []),
(int) (company_id() ?: 1),
);
return [
'supplierPayables' => $this->supplierPayablePaginator((array) ($result['rows'] ?? []), $limit, $page),
'supplierPayables' => $this->supplierPayablePaginator($rows, $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => $cache->responseHeaders(),
'supplierPayableError' => null,
];
@ -2621,6 +2639,7 @@ class Pages extends Controller
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => [],
'supplierPayableError' => $exception->getMessage(),
];
@ -2630,6 +2649,7 @@ class Pages extends Controller
return [
'supplierPayables' => $this->supplierPayablePaginator([], $limit, $page),
'supplierPayableFilters' => $filters,
'supplierPayableMaintenance' => $this->supplierPayableMaintenance->publicDefinition('supplier-payables-report'),
'supplierPayableStatus' => [],
'supplierPayableError' => '供应商应付数据暂时无法读取,请稍后重试。',
];

View File

@ -8,6 +8,7 @@ use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter;
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -16,6 +17,7 @@ class ProductPrices extends Controller
public function __construct(
private readonly ProductPriceMirrorRepository $repository,
private readonly ProductPriceWorkbookImporter $importer,
private readonly SupplierPayableImportTemplate $template,
) {
$this->middleware('auth');
$this->middleware('company.identify');
@ -93,6 +95,11 @@ class ProductPrices extends Controller
);
}
public function template(): StreamedResponse
{
return $this->template->download('商品价格', $this->importer->templateFields());
}
public function update(Request $request, string $upstreamKey): RedirectResponse
{
$values = $request->validate(

View File

@ -0,0 +1,196 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableReportRepository;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableImportTemplate;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierPayableWorkbookImporter;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierPayableActions extends Controller
{
public function __construct(
private readonly SupplierPayableRepository $supplier,
private readonly GoodsReconciliationRepository $goods,
private readonly SupplierPayableReportRepository $reports,
private readonly SupplierPayableMaintenanceRegistry $registry,
private readonly SupplierPayableImportTemplate $template,
private readonly SupplierPayableOverrideStore $overrides,
private readonly SupplierPayableWorkbookImporter $importer,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
public function definition(Request $request): JsonResponse
{
return response()->json($this->registry->publicDefinition($this->dataset($request)));
}
public function update(Request $request, string $recordKey): JsonResponse|RedirectResponse
{
$dataset = $this->dataset($request);
$values = $this->registry->normalize($dataset, (array) $request->input('values', []));
$result = $this->overrides->save(
$dataset,
$recordKey,
$values,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
return $request->expectsJson()
? response()->json(['message' => '保存成功。'] + $result)
: back()->with('success', '保存成功。');
}
public function import(Request $request): JsonResponse|RedirectResponse
{
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
$result = $this->importer->import(
$this->dataset($request),
$request->file('import'),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
$message = "导入完成:新增维护 {$result['created']} 条,更新维护 {$result['updated']} 条。";
return $request->expectsJson()
? response()->json(['message' => $message] + $result)
: back()->with('success', $message);
}
public function template(Request $request): StreamedResponse
{
$dataset = $this->dataset($request);
return $this->template->download($this->title($dataset), $this->registry->fields($dataset));
}
public function export(Request $request): StreamedResponse
{
$dataset = $this->dataset($request);
$fields = $this->registry->fields($dataset);
$filters = $this->filters($dataset, $request);
$companyId = (int) (company_id() ?: 1);
return response()->streamDownload(function () use ($dataset, $fields, $filters, $companyId): void {
$csv = fopen('php://output', 'w');
if ($csv === false) {
throw new RuntimeException('无法创建导出文件。');
}
fputs($csv, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($csv, array_merge(['记录标识'], array_column($fields, 'label')));
foreach ($this->allRows($dataset, $filters) as $batch) {
foreach ($this->overrides->decorate($dataset, $batch, $companyId) as $row) {
$values = [$this->csvText($row['_action_key'] ?? '')];
foreach ($fields as $field => $definition) {
$values[] = $definition['type'] === 'number' && is_numeric($row[$field] ?? null)
? $row[$field]
: $this->csvText($row[$field] ?? '');
}
fputcsv($csv, $values);
}
}
fclose($csv);
}, $this->title($dataset) . '_' . now()->format('Ymd_His') . '.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return iterable<array<int,array<string,mixed>>> */
private function allRows(string $dataset, array $filters): iterable
{
if ($dataset === 'payment-reconciliation' && ($filters['record_type'] ?? 'all') === 'all') {
foreach (['purchase', 'sale'] as $type) {
yield from $this->allRows($dataset, array_replace($filters, ['record_type' => $type]));
}
return;
}
$page = 1;
do {
$result = $this->page($dataset, $filters, $page, 100);
$rows = array_slice((array) ($result['data'] ?? $result['rows'] ?? []), 0, 100);
if ($rows !== []) {
yield $rows;
}
$total = (int) ($result['total'] ?? -1);
$more = $rows !== [] && ($total < 0 ? count($rows) === 100 : $page * 100 < $total);
$page++;
} while ($more);
}
private function page(string $dataset, array $filters, int $page, int $limit): array
{
return match ($dataset) {
'payment-reconciliation' => $this->goods->list($filters, $page, $limit),
'return-reconciliation' => $this->supplier->returnReconciliations($filters, $page, $limit),
'invoice-deductions' => $this->supplier->invoiceDeductions($filters, $page, $limit),
'compensation-details' => $this->supplier->compensationDetails($filters, $page, $limit),
'advance-details' => $this->supplier->advanceDetails($filters, $page, $limit),
'sample-deductions' => $this->supplier->sampleDeductions($filters, $page, $limit),
'delivery-unpriced' => $this->supplier->deliveryUnpriced($filters, $page, $limit),
'return-unpriced' => $this->supplier->returnUnpriced($filters, $page, $limit),
'other-adjustments' => $this->supplier->otherAdjustments($filters, $page, $limit),
'dropshipping-express' => $this->supplier->dropshippingExpress($filters, $page, $limit),
'board-deductions' => $this->reports->boardDeductions($filters, $page, $limit),
'supplier-payables-report' => $this->reports->supplierPayables($filters, $page, $limit),
};
}
private function filters(string $dataset, Request $request): array
{
$allowed = match ($dataset) {
'payment-reconciliation' => ['record_type', 'supplier', 'warehouse', 'stock_no', 'purchase_no', 'product_code', 'express_no', 'send_time_from', 'send_time_to', 'create_time_from', 'create_time_to', 'sent_status', 'has_diff'],
'return-reconciliation', 'return-unpriced' => ['supplier', 'warehouse', 'sku_id', 'express_no', 'date_from', 'date_to'],
'invoice-deductions', 'compensation-details', 'advance-details', 'other-adjustments' => ['supplier', 'month_from', 'month_to'],
'sample-deductions' => ['supplier', 'department', 'creator', 'sku_id', 'date_from', 'date_to'],
'delivery-unpriced' => ['supplier', 'warehouse', 'purchase', 'barcode', 'express_no', 'date_from', 'date_to'],
'dropshipping-express' => ['supplier', 'express_name', 'month_from', 'month_to'],
'board-deductions' => ['stock_in_date', 'stock_out_date', 'furniture_factory', 'lamination_factory', 'deduction_type', 'material_name', 'claim_number'],
'supplier-payables-report' => ['month', 'supplier', 'checker', 'settlement_method'],
};
return collect($request->only($allowed))
->map(static fn (mixed $value): mixed => is_string($value) ? trim($value) : $value)
->reject(static fn (mixed $value): bool => $value === '' || $value === null || $value === false)
->all();
}
private function dataset(Request $request): string
{
$dataset = (string) $request->route('dataset');
$this->registry->fields($dataset);
return $dataset;
}
private function title(string $dataset): string
{
return [
'payment-reconciliation' => '货款对账', 'return-reconciliation' => '退货对账',
'invoice-deductions' => '发票扣款', 'compensation-details' => '赔偿明细',
'advance-details' => '借支明细', 'sample-deductions' => '样品扣款',
'delivery-unpriced' => '送货未定价', 'return-unpriced' => '退货未定价',
'other-adjustments' => '其他扣款补款', 'dropshipping-express' => '代发快递',
'board-deductions' => '板材扣款', 'supplier-payables-report' => '供应商应付表',
][$dataset];
}
private function csvText(mixed $value): string
{
$value = (string) $value;
return preg_match('/^[=+\-@\t\r]/u', $value) === 1 ? "\t" . $value : $value;
}
}

View File

@ -45,6 +45,13 @@ return [
'default' => env('DB_CONNECTION', 'mysql'),
// Supplier payable edits and imports must never write to the shared
// system database used by the rest of the application.
'supplier_payable_maintenance_connection' => env(
'SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION',
'supplier_payable_maintenance'
),
/*
|--------------------------------------------------------------------------
| Database Connections
@ -169,6 +176,30 @@ return [
'prefix_indexes' => true,
'strict' => true,
'engine' => 'InnoDB ROW_FORMAT=DYNAMIC',
],
'supplier_payable_maintenance' => [
'driver' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_DRIVER', 'mysql'),
'host' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_HOST'),
'port' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_PORT', '3306'),
'database' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_DATABASE'),
'username' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_USERNAME'),
'password' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_PASSWORD'),
'unix_socket' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_SOCKET', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => env('SUPPLIER_PAYABLE_MAINTENANCE_DB_PREFIX', 'ak_'),
'prefix_indexes' => true,
'strict' => true,
'engine' => 'InnoDB ROW_FORMAT=DYNAMIC',
'modes' => [
'STRICT_TRANS_TABLES',
'NO_ZERO_IN_DATE',
'NO_ZERO_DATE',
'ERROR_FOR_DIVISION_BY_ZERO',
'NO_ENGINE_SUBSTITUTION',
],
],
'mariadb' => [

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
if (! $schema->hasTable('supplier_payable_overrides')) {
$schema->create('supplier_payable_overrides', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id');
$table->string('dataset', 64);
$table->string('record_key', 191);
$table->json('values');
$table->unsignedInteger('created_by')->nullable();
$table->unsignedInteger('updated_by')->nullable();
$table->timestamps();
$table->unique(['company_id', 'dataset', 'record_key'], 'supplier_payable_override_unique');
$table->index(['company_id', 'dataset'], 'supplier_payable_override_dataset');
});
}
if (! $schema->hasTable('supplier_payable_override_events')) {
$schema->create('supplier_payable_override_events', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->unsignedInteger('company_id');
$table->string('dataset', 64);
$table->string('record_key', 191);
$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', 'dataset', 'record_key'], 'supplier_payable_override_event_lookup');
});
}
}
public function down(): void
{
$schema = Schema::connection(config('database.supplier_payable_maintenance_connection'));
$schema->dropIfExists('supplier_payable_override_events');
$schema->dropIfExists('supplier_payable_overrides');
}
};

View File

@ -0,0 +1,110 @@
<?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('pdd_settlement_bill_imports')) {
$schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void {
$table->uuid('generation')->primary();
$table->string('source_filename');
$table->char('source_sha256', 64)->unique();
$table->date('period_start');
$table->date('period_end');
$table->string('status', 32)->default('running');
$table->unsignedBigInteger('expected_row_count');
$table->decimal('expected_income_total', 20, 2);
$table->decimal('expected_expense_total', 20, 2);
$table->unsignedBigInteger('row_count')->default(0);
$table->unsignedBigInteger('excluded_account_rows')->default(0);
$table->unsignedInteger('store_count')->default(0);
$table->unsignedInteger('bill_item_count')->default(0);
$table->json('account_type_counts')->nullable();
$table->decimal('income_total', 20, 2)->default(0);
$table->decimal('expense_total', 20, 2)->default(0);
$table->decimal('net_total', 20, 2)->default(0);
$table->string('last_error_code', 120)->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
$table->index(['status', 'period_start', 'period_end'], 'pdd_bill_import_period_idx');
});
}
if (! $schema->hasTable('pdd_settlement_bill_staging')) {
$schema->create('pdd_settlement_bill_staging', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('generation');
$this->billColumns($table);
$table->unique(['generation', 'record_code'], 'pdd_bill_stage_generation_record_uq');
$table->index(['generation', 'occurred_at'], 'pdd_bill_stage_generation_time_idx');
});
}
if (! $schema->hasTable('pdd_settlement_bill_rows')) {
$schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void {
$table->string('record_code', 191)->primary();
$this->billColumns($table, false);
$table->uuid('source_generation');
$table->char('source_sha256', 64);
$table->timestamp('imported_at');
$table->index(['occurred_at', 'record_code'], 'pdd_bill_time_record_idx');
$table->index(['store_name', 'occurred_at'], 'pdd_bill_store_time_idx');
$table->index(['bill_item', 'occurred_at'], 'pdd_bill_item_time_idx');
});
}
}
public function down(): void
{
if (app()->environment('testing')
|| (string) config('system_database.mode', 'bootstrap') === 'bootstrap') {
return;
}
$schema = Schema::connection($this->connection());
$schema->dropIfExists('pdd_settlement_bill_rows');
$schema->dropIfExists('pdd_settlement_bill_staging');
$schema->dropIfExists('pdd_settlement_bill_imports');
}
private function billColumns(Blueprint $table, bool $includeRecordCode = true): void
{
if ($includeRecordCode) {
$table->string('record_code', 191);
}
$table->string('store_name')->nullable();
$table->string('custom_remark')->nullable();
$table->string('bill_item')->nullable();
$table->string('merchant_order_no', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->string('business_type')->nullable();
$table->string('business_description', 1000)->nullable();
$table->string('account_name')->nullable();
$table->dateTime('occurred_at');
$table->decimal('income_amount', 20, 2)->default(0);
$table->decimal('expense_amount', 20, 2)->default(0);
$table->decimal('signed_amount', 20, 2)->default(0);
$table->string('remark', 1000)->nullable();
$table->dateTime('source_updated_at')->nullable();
$table->string('source_modified_by')->nullable();
$table->unsignedBigInteger('source_row_number');
}
};

View File

@ -384,6 +384,11 @@ fi
echo "==> 自动检查本机临时引导库结构..."
docker compose -f "$compose_file" exec -T -u www-data app php artisan optimize:clear >/dev/null
echo "==> 自动检查供应商应付本地维护库结构..."
docker compose -f "$compose_file" exec -T -u www-data app \
php artisan migrate --database=supplier_payable_maintenance \
--path=database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php \
--force --no-ansi
docker compose -f "$compose_file" exec -T -u www-data app \
php artisan local-bootstrap:update --no-ansi

View File

@ -20,6 +20,18 @@ services:
depends_on:
mysql:
condition: service_healthy
environment:
# Local test data entry uses PATCH requests; do not let the global
# firewall heuristics block the maintenance-only test environment.
FIREWALL_ENABLED: "false"
SUPPLIER_PAYABLE_MAINTENANCE_DB_CONNECTION: supplier_payable_maintenance
SUPPLIER_PAYABLE_MAINTENANCE_DB_DRIVER: mysql
SUPPLIER_PAYABLE_MAINTENANCE_DB_HOST: mysql
SUPPLIER_PAYABLE_MAINTENANCE_DB_PORT: 3306
SUPPLIER_PAYABLE_MAINTENANCE_DB_DATABASE: akaunting
SUPPLIER_PAYABLE_MAINTENANCE_DB_USERNAME: akaunting
SUPPLIER_PAYABLE_MAINTENANCE_DB_PASSWORD: akaunting123
SUPPLIER_PAYABLE_MAINTENANCE_DB_PREFIX: ak_
ports:
- "127.0.0.1:18088:18088"
volumes:

View File

@ -5,6 +5,7 @@ namespace Modules\EcommerceFinance\Http\Controllers\Api;
use App\Abstracts\Http\ApiController;
use Modules\EcommerceFinance\Repositories\GoodsReconciliationRepository;
use Modules\EcommerceFinance\Services\JianSunQueryCache;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
/**
* 货款对账 API
@ -20,7 +21,7 @@ class GoodsReconciliations extends ApiController
private JianSunQueryCache $cache;
public function __construct(GoodsReconciliationRepository $repo, JianSunQueryCache $cache)
public function __construct(GoodsReconciliationRepository $repo, JianSunQueryCache $cache, private readonly SupplierPayableOverrideStore $overrides)
{
$this->repo = $repo;
$this->cache = $cache;
@ -41,6 +42,8 @@ class GoodsReconciliations extends ApiController
fn () => $this->repo->list($filters, $page, $limit)
);
$result['data'] = $this->overrides->decorate('payment-reconciliation', $result['data'] ?? [], (int) (company_id() ?: 1));
return $this->snapshotResponse([
'data' => $result['data'],
'total' => $result['total'],

View File

@ -6,6 +6,7 @@ use App\Abstracts\Http\ApiController;
use Closure;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Services\JianSunQueryCache;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
/**
* 供应商应付 - 子功能页面 API
@ -23,7 +24,7 @@ class SupplierPayables extends ApiController
private JianSunQueryCache $cache;
public function __construct(SupplierPayableRepository $repo, JianSunQueryCache $cache)
public function __construct(SupplierPayableRepository $repo, JianSunQueryCache $cache, private readonly SupplierPayableOverrideStore $overrides)
{
$this->repo = $repo;
$this->cache = $cache;
@ -333,12 +334,18 @@ class SupplierPayables extends ApiController
{
$page = $this->page();
$limit = $this->limit();
$cacheParameters = ['filters' => $filters, 'page' => $page, 'limit' => $limit];
if ($scope === 'other-adjustments') {
$cacheParameters['format_version'] = 2;
}
$result = $this->cache->remember(
$scope . '.list',
['filters' => $filters, 'page' => $page, 'limit' => $limit],
$cacheParameters,
fn () => $resolver($page, $limit)
);
$result['data'] = $this->overrides->decorate($scope, $result['data'] ?? [], (int) (company_id() ?: 1));
return $this->snapshotResponse([
'data' => $result['data'],
'total' => $result['total'],

View File

@ -0,0 +1,178 @@
<?php
namespace Modules\EcommerceFinance\Http\Controllers;
use App\Utilities\SidebarPermissions;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\AccountBillColumns;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use RuntimeException;
class PinduoduoAccountBills extends Controller
{
public function index(Request $request, PinduoduoSettlementBillRepository $repository)
{
$this->authorizeStoreReceivingBills();
if ($request->boolean('export')) {
return $this->exportResponse($request, $repository);
}
$validated = $request->validate($this->filterRules() + [
'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'],
'dir' => ['nullable', 'in:asc,desc'],
'page' => ['nullable', 'integer', 'min:1'],
'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$filters = collect($validated)->only(array_keys($this->filterRules()))->all();
try {
$data = $repository->paginateAccountBills(
$filters,
(string) ($validated['sort'] ?? 'occurred_at'),
(string) ($validated['dir'] ?? 'desc'),
(int) ($validated['page'] ?? 1),
(int) ($validated['limit'] ?? 10),
);
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') {
throw $exception;
}
return response()->json([
'message' => '拼多多完整货款账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。',
'data' => [],
'total' => 0,
'current_page' => 1,
'last_page' => 1,
'per_page' => (int) ($validated['limit'] ?? 10),
'summary' => ['income' => 0, 'expense' => 0, 'balance' => 0, 'count' => 0],
'columns' => AccountBillColumns::for('pinduoduo'),
], 503);
}
$data['columns'] = AccountBillColumns::for('pinduoduo');
return response()->json($data);
}
public function filterOptions(PinduoduoSettlementBillRepository $repository)
{
$this->authorizeStoreReceivingBills();
try {
return response()->json($repository->accountBillFilterOptions());
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') {
throw $exception;
}
return response()->json([
'message' => '拼多多完整货款账单尚未完成首次导入。',
'shops' => [],
'bill_items' => [],
], 503);
}
}
public function export(Request $request, PinduoduoSettlementBillRepository $repository)
{
$this->authorizeStoreReceivingBills();
return $this->exportResponse($request, $repository);
}
private function exportResponse(Request $request, PinduoduoSettlementBillRepository $repository)
{
$validated = $request->validate($this->filterRules() + [
'sort' => ['nullable', 'in:occurred_at,income,expense,record_code'],
'dir' => ['nullable', 'in:asc,desc'],
]);
$filters = collect($validated)->only(array_keys($this->filterRules()))->all();
$definition = AccountBillColumns::for('pinduoduo');
$available = collect($definition['columns'] ?? [])->keyBy('field');
$requested = $request->query('columns', []);
if (is_string($requested)) {
$requested = array_filter(array_map('trim', explode(',', $requested)));
}
if (! is_array($requested) || count($requested) > 40) {
$requested = [];
}
$requested = collect($requested)
->filter(static fn (mixed $value): bool => is_scalar($value))
->map(static fn (mixed $value): string => mb_substr(trim((string) $value), 0, 128))
->filter()
->unique();
$columns = $requested->isEmpty()
? $available->values()
: $requested->map(fn (string $field) => $available->get($field))->filter()->values();
abort_if($columns->isEmpty(), 422, '至少选择一个可导出的字段。');
try {
$rows = $repository->exportAccountBills(
$filters,
(string) ($validated['sort'] ?? 'occurred_at'),
(string) ($validated['dir'] ?? 'asc'),
);
} catch (RuntimeException $exception) {
if ($exception->getMessage() !== 'pinduoduo_authoritative_bills_not_ready') {
throw $exception;
}
return response()->json(['message' => '拼多多完整货款账单尚未完成首次导入。'], 503);
}
return response()->streamDownload(function () use ($rows, $columns): void {
$csv = fopen('php://output', 'w');
fputs($csv, "\xEF\xBB\xBF");
fputcsv($csv, $columns->pluck('label')->all());
foreach ($rows as $row) {
fputcsv($csv, $columns->map(
fn (array $column): string => $this->exportValue($row, $column)
)->all());
}
fclose($csv);
}, 'pinduoduo_account_bills.csv', [
'Content-Type' => 'text/csv; charset=UTF-8',
'Cache-Control' => 'private, no-store',
]);
}
/** @return array<string,array<int,string>> */
private function filterRules(): array
{
return [
'store_name' => ['nullable', 'string', 'max:255'],
'bill_item' => ['nullable', 'string', 'max:255'],
'start' => ['nullable', 'date_format:Y-m-d'],
'end' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start'],
'keyword' => ['nullable', 'string', 'max:255'],
];
}
/** @param array<string,mixed> $row */
private function exportValue(array $row, array $column): string
{
$field = (string) ($column['field'] ?? '');
$value = str_starts_with($field, 'payload.')
? data_get($row['payload'] ?? [], substr($field, 8))
: data_get($row, $field);
$text = (string) ($value ?? '');
if (preg_match('/^\d{12,}$/', $text) === 1) {
return '="' . $text . '"';
}
return preg_match('/^[=+\-@]/u', $text) === 1 ? "'" . $text : $text;
}
private function authorizeStoreReceivingBills(): void
{
$section = collect(SidebarPermissions::navigationFor(user()))
->firstWhere('slug', 'billing-transactions');
$page = collect($section['pages'] ?? [])
->firstWhere('slug', 'store-receiving-bills');
abort_if(empty($page), 403, '当前账号没有店铺收款账户账单权限。');
}
}

View File

@ -0,0 +1,304 @@
<?php
namespace Modules\EcommerceFinance\Repositories;
use DateTimeImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Schema;
use RuntimeException;
class PinduoduoSettlementBillRepository
{
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
private const ROW_TABLE = 'pdd_settlement_bill_rows';
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
public function ready(): bool
{
$schema = Schema::connection($this->connection->getName());
if (! $schema->hasTable(self::IMPORT_TABLE) || ! $schema->hasTable(self::ROW_TABLE)) {
return false;
}
return $this->connection->table(self::IMPORT_TABLE)->where('status', 'succeeded')->exists();
}
/** @return string[] */
public function storeOptions(): array
{
return $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
->distinct()->orderBy('store_name')->pluck('store_name')->map('strval')->all();
}
/** @return array{shops:list<string>,bill_items:list<string>} */
public function accountBillFilterOptions(): array
{
$shops = $this->query()->whereNotNull('store_name')->where('store_name', '<>', '')
->distinct()->orderBy('store_name')->pluck('store_name')
->map(static fn (mixed $value): string => (string) $value)->all();
$billItems = $this->query()->whereNotNull('bill_item')->where('bill_item', '<>', '')
->distinct()->orderBy('bill_item')->pluck('bill_item')
->map(static fn (mixed $value): string => (string) $value)->all();
return ['shops' => $shops, 'bill_items' => $billItems];
}
/** @return array{rows:list<array<string,mixed>>,total:int} */
public function paginate(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
int $page,
int $pageSize,
): array {
$page = max(1, $page);
$pageSize = min(max(1, $pageSize), 500);
$query = $this->query();
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
$total = (clone $query)->count();
$rows = $query->orderByDesc('occurred_at')->orderByDesc('record_code')
->forPage($page, $pageSize)->get()->map(function (object $row): array {
$signed = (float) $row->signed_amount;
$income = (float) $row->income_amount;
$expense = (float) $row->expense_amount;
$flowType = $income > 0 ? 1 : ($expense > 0 ? 2 : 0);
return [
'id' => $row->record_code,
'shopName' => (string) ($row->store_name ?? ''),
'amount' => abs($signed),
'balance' => null,
'summary' => (string) ($row->bill_item ?? ''),
'flowType' => $flowType,
'toAdvertiserFundType' => null,
'createTime' => (string) $row->occurred_at,
'source_primary_key' => (string) ($row->merchant_order_no ?? ''),
'source_record_code' => (string) $row->record_code,
'source_channel' => 'PinduoduoSettlementBill',
];
})->all();
return ['rows' => $rows, 'total' => $total];
}
/** @return array{income_total:float,expense_total:float,cnt:int} */
public function summarize(
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): array {
$query = $this->query();
$this->applyFilters($query, $storeName, $fromDate, $toDate, $flowType);
$row = $query->selectRaw(
'COALESCE(SUM(income_amount), 0) AS income_total, '
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS cnt'
)->first();
return [
'income_total' => (float) ($row->income_total ?? 0),
'expense_total' => (float) ($row->expense_total ?? 0),
'cnt' => (int) ($row->cnt ?? 0),
];
}
/** @return array<string,mixed> */
public function paginateAccountBills(
array $filters,
string $sort,
string $direction,
int $page,
int $perPage,
): array {
$page = max(1, $page);
$perPage = min(max(1, $perPage), 100);
$query = $this->query();
$this->applyAccountBillFilters($query, $filters);
$summary = (clone $query)->selectRaw(
'COALESCE(SUM(income_amount), 0) AS income_total, '
. 'COALESCE(SUM(expense_amount), 0) AS expense_total, COUNT(*) AS row_count'
)->first();
$total = (int) ($summary->row_count ?? 0);
$income = (float) ($summary->income_total ?? 0);
$expense = (float) ($summary->expense_total ?? 0);
$sortColumn = $this->accountBillSortColumn($sort);
$direction = $direction === 'asc' ? 'asc' : 'desc';
$query->orderBy($sortColumn, $direction);
if ($sortColumn !== 'record_code') {
$query->orderBy('record_code', $direction);
}
$rows = $query->forPage($page, $perPage)->get()
->map(fn (object $row): array => $this->mapAccountBillRow($row))->all();
return [
'data' => $rows,
'total' => $total,
'current_page' => $page,
'last_page' => max(1, (int) ceil($total / $perPage)),
'per_page' => $perPage,
'from' => $total === 0 ? null : (($page - 1) * $perPage) + 1,
'to' => $total === 0 ? null : min($page * $perPage, $total),
'summary' => [
'income' => $income,
'expense' => $expense,
'balance' => round($income - $expense, 2),
'count' => $total,
],
];
}
/** @return iterable<int,array<string,mixed>> */
public function exportAccountBills(array $filters, string $sort, string $direction): iterable
{
$query = $this->query();
$this->applyAccountBillFilters($query, $filters);
$sortColumn = $this->accountBillSortColumn($sort);
$direction = $direction === 'desc' ? 'desc' : 'asc';
$query->orderBy($sortColumn, $direction);
if ($sortColumn !== 'record_code') {
$query->orderBy('record_code', $direction);
}
return $query->cursor()->map(fn (object $row): array => $this->mapAccountBillRow($row));
}
private function query(): Builder
{
if (! $this->ready()) {
throw new RuntimeException('pinduoduo_authoritative_bills_not_ready');
}
return $this->connection->table(self::ROW_TABLE);
}
private function applyFilters(
Builder $query,
?string $storeName,
?string $fromDate,
?string $toDate,
?string $flowType,
): void {
if ($storeName !== null && $storeName !== '') {
$query->where('store_name', $storeName);
}
$from = $this->date($fromDate);
if ($from !== null) {
$query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00');
}
$to = $this->date($toDate);
if ($to !== null) {
$query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
}
if ($flowType === 'income') {
$query->where('income_amount', '>', 0);
} elseif ($flowType === 'expense') {
$query->where('expense_amount', '>', 0);
}
}
/** @param array<string,mixed> $filters */
private function applyAccountBillFilters(Builder $query, array $filters): void
{
$storeName = trim((string) ($filters['store_name'] ?? ''));
if ($storeName !== '') {
$query->where('store_name', $storeName);
}
$billItem = trim((string) ($filters['bill_item'] ?? ''));
if ($billItem !== '') {
$query->where('bill_item', $billItem);
}
$from = $this->date(isset($filters['start']) ? (string) $filters['start'] : null);
if ($from !== null) {
$query->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00');
}
$to = $this->date(isset($filters['end']) ? (string) $filters['end'] : null);
if ($to !== null) {
$query->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00');
}
$keyword = trim((string) ($filters['keyword'] ?? ''));
if ($keyword !== '') {
$query->where(function (Builder $nested) use ($keyword): void {
$like = '%' . $keyword . '%';
$nested->where('record_code', 'like', $like)
->orWhere('merchant_order_no', 'like', $like)
->orWhere('online_order_no', 'like', $like)
->orWhere('remark', 'like', $like)
->orWhere('custom_remark', 'like', $like)
->orWhere('business_description', 'like', $like)
->orWhere('bill_item', 'like', $like);
});
}
}
private function accountBillSortColumn(string $sort): string
{
return [
'occurred_at' => 'occurred_at',
'income' => 'income_amount',
'expense' => 'expense_amount',
'record_code' => 'record_code',
][$sort] ?? 'occurred_at';
}
/** @return array<string,mixed> */
private function mapAccountBillRow(object $row): array
{
$signedAmount = (float) $row->signed_amount;
return [
'id' => (string) $row->record_code,
'record_code' => (string) $row->record_code,
'store_name' => (string) ($row->store_name ?? ''),
'finance_remark' => (string) ($row->business_description ?? ''),
'order_no' => (string) ($row->merchant_order_no ?? ''),
'amount' => abs($signedAmount),
'custom_remark' => (string) ($row->custom_remark ?? ''),
'bill_item' => (string) ($row->bill_item ?? ''),
'occurred_at' => (string) $row->occurred_at,
'income' => (float) $row->income_amount,
'expense' => (float) $row->expense_amount,
'balance' => $signedAmount,
'remark' => (string) ($row->remark ?? ''),
'updated_at' => $row->source_updated_at !== null
? (string) $row->source_updated_at
: null,
'modifier' => (string) ($row->source_modified_by ?? ''),
'payload' => [
'merchant_order_no' => (string) ($row->merchant_order_no ?? ''),
'online_order_no' => (string) ($row->online_order_no ?? ''),
'finance_type' => (string) ($row->business_type ?? ''),
'business_description' => (string) ($row->business_description ?? ''),
'type' => (string) ($row->account_name ?? ''),
],
];
}
private function date(?string $value): ?DateTimeImmutable
{
if ($value === null || preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return null;
}
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $value);
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
}
}

View File

@ -135,6 +135,7 @@ class SupplierPayableRepository
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
id AS id,
CONVERT(varchar(10), date, 120) AS send_time,
supplierName AS supplier,
sku_id AS barcode,
@ -225,6 +226,7 @@ class SupplierPayableRepository
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
id AS id,
CONVERT(varchar(7), month, 120) AS month_str,
'' AS category,
supplier AS payee,
@ -281,6 +283,7 @@ class SupplierPayableRepository
$rows = $this->query->select(
"SELECT
[id] AS id,
[Month] AS month,
[supplier] AS supplier,
CAST([Totalindemnity] AS decimal(18,2)) AS amount,
@ -369,6 +372,7 @@ class SupplierPayableRepository
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
id AS id,
supplier,
'' AS contract_name,
CAST(price AS decimal(18,2)) AS advance_amount,
@ -454,6 +458,7 @@ class SupplierPayableRepository
$whereSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$sql = "SELECT
id AS id,
CONVERT(varchar(10), finishTime, 120) AS finish_time,
department,
creator,
@ -619,6 +624,7 @@ class SupplierPayableRepository
$whereSql = 'WHERE ' . implode(' AND ', $where);
$sql = "SELECT
id AS id,
CONVERT(varchar(10), date, 120) AS send_time,
supplierName AS supplier,
sku_id AS barcode,
@ -759,7 +765,7 @@ class SupplierPayableRepository
$whereSql = 'WHERE ' . implode(' AND ', $where);
$sql = "SELECT
'其他扣款补款' AS project,
id AS id,
Month AS month_str,
supplier,
CAST(other AS decimal(18,2)) AS supplement_amount,
@ -770,7 +776,13 @@ class SupplierPayableRepository
FROM fys_supplierPaymentSummary
$whereSql";
return $this->runQuery($sql, $params, $page, $pageSize, 'month_str DESC, supplier');
$result = $this->runQuery($sql, $params, $page, $pageSize, 'month_str DESC, supplier');
foreach ($result['data'] as &$row) {
$row['project'] = '其他扣款补款';
}
unset($row);
return $result;
}
public function otherAdjustmentsSummary(array $filters): array
@ -825,6 +837,7 @@ class SupplierPayableRepository
$whereSql = 'WHERE ' . implode(' AND ', $where);
$sql = "SELECT
id AS id,
reconciliation_person,
month AS month_str,
courier_company_name AS supplier,

View File

@ -0,0 +1,709 @@
<?php
namespace Modules\EcommerceFinance\Services;
use DateTimeImmutable;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Database\DatabaseManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
use SimpleXMLElement;
use Throwable;
use XMLReader;
use ZipArchive;
class PinduoduoSettlementBillImporter
{
public const ACCOUNT_NAME = '拼多多货款账户';
private const IMPORT_TABLE = 'pdd_settlement_bill_imports';
private const STAGING_TABLE = 'pdd_settlement_bill_staging';
private const ROW_TABLE = 'pdd_settlement_bill_rows';
private const HEADER_ALIASES = [
'record_code' => ['记录编码'],
'store_name' => ['店铺', '原始店铺'],
'custom_remark' => ['自定义备注'],
'bill_item' => ['账单项目'],
'merchant_order_no' => ['商户订单号'],
'online_order_no' => ['线上单号'],
'business_type' => ['财务类型', '业务类型'],
'business_description' => ['业务描述'],
'account_name' => ['类型', '账户'],
'occurred_at' => ['发生时间'],
'income_amount' => ['收入金额'],
'expense_amount' => ['支出金额'],
'signed_amount' => ['金额'],
'remark' => ['备注'],
'source_updated_at' => ['修改时间'],
'source_modified_by' => ['修改人'],
];
private readonly ConnectionInterface $connection;
public function __construct(DatabaseManager $databases)
{
$this->connection = $databases->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
}
/**
* @return array{status:string,row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array<string,int>,income_total:string,expense_total:string,net_total:string,period_start:string,period_end:string,source_sha256:string}
*/
public function import(
string $path,
string $fromDate,
string $toDate,
int $expectedCount,
string $expectedIncome,
string $expectedExpense,
int $batchSize = 1000,
): array {
$path = $this->validatedPath($path);
[$from, $to] = $this->period($fromDate, $toDate);
$this->assertSchemaReady();
if ($expectedCount <= 0) {
throw new RuntimeException('权威笔数控制数必须大于零。');
}
$expectedIncome = $this->controlAmount($expectedIncome, '收入');
$expectedExpense = $this->controlAmount($expectedExpense, '支出');
$batchSize = min(max($batchSize, 100), 5000);
$hash = hash_file('sha256', $path);
if (! is_string($hash)) {
throw new RuntimeException('无法计算拼多多完整账单文件摘要。');
}
$existing = $this->connection->table(self::IMPORT_TABLE)
->where('source_sha256', $hash)
->first();
if ($existing !== null && $existing->status === 'succeeded') {
$this->assertSameImportRequest(
$existing,
$from,
$to,
$expectedCount,
$expectedIncome,
$expectedExpense,
);
if ($this->activeRowCount($hash, $from, $to) === (int) $existing->row_count) {
return $this->result((array) $existing, 'unchanged');
}
}
$generation = $existing !== null ? (string) $existing->generation : (string) Str::uuid();
$now = now();
$import = [
'generation' => $generation,
'source_filename' => mb_substr(basename($path), 0, 255),
'source_sha256' => $hash,
'period_start' => $from->format('Y-m-d'),
'period_end' => $to->format('Y-m-d'),
'status' => 'running',
'expected_row_count' => $expectedCount,
'expected_income_total' => $expectedIncome,
'expected_expense_total' => $expectedExpense,
'row_count' => 0,
'excluded_account_rows' => 0,
'store_count' => 0,
'bill_item_count' => 0,
'account_type_counts' => null,
'income_total' => '0.00',
'expense_total' => '0.00',
'net_total' => '0.00',
'completed_at' => null,
'last_error_code' => null,
'updated_at' => $now,
];
if ($existing === null) {
$this->connection->table(self::IMPORT_TABLE)->insert($import + ['created_at' => $now]);
} else {
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($import);
}
$stats = null;
try {
$stats = $this->stage($path, $generation, $from, $to, $batchSize);
$this->assertControls($stats, $expectedCount, $expectedIncome, $expectedExpense);
$this->activate($generation, $hash, $from, $to, $stats, $batchSize);
return $this->result([
'row_count' => $stats['row_count'],
'excluded_account_rows' => $stats['excluded_account_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'account_type_counts' => $stats['account_type_counts'],
'income_total' => $stats['income_total'],
'expense_total' => $stats['expense_total'],
'net_total' => $stats['net_total'],
'period_start' => $from->format('Y-m-d'),
'period_end' => $to->format('Y-m-d'),
'source_sha256' => $hash,
], 'succeeded');
} catch (Throwable $exception) {
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
$failure = [
'status' => 'failed',
'last_error_code' => mb_substr(class_basename($exception), 0, 120),
'updated_at' => now(),
];
if (is_array($stats)) {
$failure += [
'row_count' => $stats['row_count'],
'excluded_account_rows' => $stats['excluded_account_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'income_total' => $stats['income_total'],
'expense_total' => $stats['expense_total'],
'net_total' => $stats['net_total'],
];
}
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update($failure);
throw $exception;
}
}
private function validatedPath(string $path): string
{
$realPath = realpath($path);
if ($realPath === false || ! is_file($realPath) || ! is_readable($realPath)) {
throw new RuntimeException('拼多多完整账单文件不存在或不可读。');
}
if (strtolower((string) pathinfo($realPath, PATHINFO_EXTENSION)) !== 'xlsx') {
throw new RuntimeException('聚胜算完整账单导入只支持 XLSX 文件。');
}
return $realPath;
}
/** @return array{0:DateTimeImmutable,1:DateTimeImmutable} */
private function period(string $fromDate, string $toDate): array
{
$from = DateTimeImmutable::createFromFormat('!Y-m-d', $fromDate);
$to = DateTimeImmutable::createFromFormat('!Y-m-d', $toDate);
if ($from === false || $to === false
|| $from->format('Y-m-d') !== $fromDate
|| $to->format('Y-m-d') !== $toDate
|| $from > $to) {
throw new RuntimeException('拼多多账单导入账期无效。');
}
return [$from, $to];
}
private function assertSchemaReady(): void
{
$schema = Schema::connection($this->connection->getName());
foreach ([self::IMPORT_TABLE, self::STAGING_TABLE, self::ROW_TABLE] as $table) {
if (! $schema->hasTable($table)) {
throw new RuntimeException('拼多多完整账单表尚未完成迁移:' . $table);
}
}
}
/**
* @return array{row_count:int,excluded_account_rows:int,store_count:int,bill_item_count:int,account_type_counts:array<string,int>,income_total:string,expense_total:string,net_total:string}
*/
private function stage(
string $path,
string $generation,
DateTimeImmutable $from,
DateTimeImmutable $to,
int $batchSize,
): array {
$archive = new ZipArchive();
if ($archive->open($path) !== true) {
throw new RuntimeException('无法打开拼多多完整账单 XLSX 文件。');
}
try {
$worksheet = $this->firstWorksheet($archive);
$sharedStrings = $this->sharedStrings($path, $archive);
} finally {
$archive->close();
}
$reader = new XMLReader();
if (! $reader->open($this->zipUri($path, $worksheet), null, LIBXML_NONET | LIBXML_COMPACT)) {
throw new RuntimeException('无法读取拼多多完整账单工作表。');
}
$headerMap = null;
$batch = [];
$rowCount = 0;
$excluded = 0;
$incomeTotal = '0.00';
$expenseTotal = '0.00';
$stores = [];
$billItems = [];
$accountTypeCounts = [];
try {
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'row') {
continue;
}
$rowNumber = (int) ($reader->getAttribute('r') ?: 0);
$cells = $this->rowCells($reader->readOuterXml(), $sharedStrings);
if ($headerMap === null) {
$headerMap = $this->headerMap($cells);
continue;
}
$values = [];
foreach ($headerMap as $header => $column) {
$values[$header] = trim((string) ($cells[$column] ?? ''));
}
if (implode('', $values) === '') {
continue;
}
$recordCode = $this->required($values, 'record_code', $rowNumber, '记录编码');
$accountName = $this->required($values, 'account_name', $rowNumber, '账户类型');
$storeName = $this->nullable($values['store_name']);
$billItem = $this->nullable($values['bill_item']);
$occurredAt = $this->dateTime($values['occurred_at'], $rowNumber, '发生时间');
if ($occurredAt < $from->format('Y-m-d') . ' 00:00:00'
|| $occurredAt >= $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00') {
throw new RuntimeException("第 {$rowNumber} 行发生时间超出指定账期。");
}
$income = $this->money($values['income_amount'], $rowNumber, '收入金额', false);
$expense = $this->money($values['expense_amount'], $rowNumber, '支出金额', true);
$signed = $this->money($values['signed_amount'], $rowNumber, '金额', false, true);
$calculated = bcsub($income, $expense, 2);
if (bccomp($calculated, $signed, 2) !== 0) {
throw new RuntimeException("第 {$rowNumber} 行收入、支出与金额不一致。");
}
$batch[] = [
'generation' => $generation,
'record_code' => mb_substr($recordCode, 0, 191),
'store_name' => $storeName,
'custom_remark' => $this->nullable($values['custom_remark']),
'bill_item' => $billItem,
'merchant_order_no' => $this->nullable($values['merchant_order_no'], 191),
'online_order_no' => $this->nullable($values['online_order_no'], 191),
'business_type' => $this->nullable($values['business_type']),
'business_description' => $this->nullable($values['business_description'], 1000),
// Keep the account type from the authoritative export. The
// complete Jushengsuan file contains both wallet and marketing
// account rows, and the finance page must retain both.
'account_name' => mb_substr($accountName, 0, 255),
'occurred_at' => $occurredAt,
'income_amount' => $income,
'expense_amount' => $expense,
'signed_amount' => $signed,
'remark' => $this->nullable($values['remark'], 1000),
'source_updated_at' => $this->optionalDateTime($values['source_updated_at'], $rowNumber, '修改时间'),
'source_modified_by' => $this->nullable($values['source_modified_by']),
'source_row_number' => $rowNumber,
];
$rowCount++;
if ($storeName !== null) {
$stores[$storeName] = true;
}
if ($billItem !== null) {
$billItems[$billItem] = true;
}
$accountTypeCounts[$accountName] = ($accountTypeCounts[$accountName] ?? 0) + 1;
$incomeTotal = bcadd($incomeTotal, $income, 2);
$expenseTotal = bcadd($expenseTotal, $expense, 2);
if (count($batch) >= $batchSize) {
$this->connection->table(self::STAGING_TABLE)->insert($batch);
$batch = [];
}
}
} finally {
$reader->close();
}
if ($headerMap === null) {
throw new RuntimeException('未找到聚胜算拼多多账单标准表头。');
}
if ($batch !== []) {
$this->connection->table(self::STAGING_TABLE)->insert($batch);
}
if ($rowCount === 0) {
throw new RuntimeException('文件中没有拼多多货款账户数据。');
}
return [
'row_count' => $rowCount,
'excluded_account_rows' => $excluded,
'store_count' => count($stores),
'bill_item_count' => count($billItems),
'account_type_counts' => $accountTypeCounts,
'income_total' => $incomeTotal,
'expense_total' => $expenseTotal,
'net_total' => bcsub($incomeTotal, $expenseTotal, 2),
];
}
private function assertControls(array $stats, int $count, string $income, string $expense): void
{
if ($stats['row_count'] !== $count
|| bccomp($stats['income_total'], $income, 2) !== 0
|| bccomp($stats['expense_total'], $expense, 2) !== 0) {
throw new RuntimeException(sprintf(
'权威控制数不一致:实际 %d 笔,收入 %s,支出 %s;期望 %d 笔,收入 %s,支出 %s。',
$stats['row_count'],
$stats['income_total'],
$stats['expense_total'],
$count,
$income,
$expense,
));
}
}
private function assertSameImportRequest(
object $existing,
DateTimeImmutable $from,
DateTimeImmutable $to,
int $expectedCount,
string $expectedIncome,
string $expectedExpense,
): void {
if ((string) $existing->period_start !== $from->format('Y-m-d')
|| (string) $existing->period_end !== $to->format('Y-m-d')
|| (int) $existing->expected_row_count !== $expectedCount
|| bccomp((string) $existing->expected_income_total, $expectedIncome, 2) !== 0
|| bccomp((string) $existing->expected_expense_total, $expectedExpense, 2) !== 0) {
throw new RuntimeException('该文件已有成功导入记录,但本次账期或权威控制数与历史记录不一致。');
}
}
private function activeRowCount(
string $hash,
DateTimeImmutable $from,
DateTimeImmutable $to,
): int {
return $this->connection->table(self::ROW_TABLE)
->where('source_sha256', $hash)
->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00')
->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
->count();
}
private function activate(
string $generation,
string $hash,
DateTimeImmutable $from,
DateTimeImmutable $to,
array $stats,
int $batchSize,
): void {
$now = now();
$columns = [
'store_name', 'custom_remark', 'bill_item', 'merchant_order_no', 'online_order_no',
'business_type', 'business_description', 'account_name', 'occurred_at', 'income_amount',
'expense_amount', 'signed_amount', 'remark', 'source_updated_at', 'source_modified_by',
'source_row_number', 'source_generation', 'source_sha256', 'imported_at',
];
$this->connection->transaction(function () use (
$generation, $hash, $from, $to, $stats, $batchSize, $columns, $now
): void {
$this->connection->table(self::ROW_TABLE)
->where('occurred_at', '>=', $from->format('Y-m-d') . ' 00:00:00')
->where('occurred_at', '<', $to->modify('+1 day')->format('Y-m-d') . ' 00:00:00')
->delete();
$this->connection->table(self::STAGING_TABLE)
->where('generation', $generation)
->orderBy('id')
->chunkById($batchSize, function ($rows) use ($generation, $hash, $columns, $now): void {
$payload = [];
foreach ($rows as $row) {
$payload[] = [
'record_code' => $row->record_code,
'store_name' => $row->store_name,
'custom_remark' => $row->custom_remark,
'bill_item' => $row->bill_item,
'merchant_order_no' => $row->merchant_order_no,
'online_order_no' => $row->online_order_no,
'business_type' => $row->business_type,
'business_description' => $row->business_description,
'account_name' => $row->account_name,
'occurred_at' => $row->occurred_at,
'income_amount' => $row->income_amount,
'expense_amount' => $row->expense_amount,
'signed_amount' => $row->signed_amount,
'remark' => $row->remark,
'source_updated_at' => $row->source_updated_at,
'source_modified_by' => $row->source_modified_by,
'source_row_number' => $row->source_row_number,
'source_generation' => $generation,
'source_sha256' => $hash,
'imported_at' => $now,
];
}
$this->connection->table(self::ROW_TABLE)->upsert($payload, ['record_code'], $columns);
});
$this->connection->table(self::IMPORT_TABLE)->where('generation', $generation)->update([
'status' => 'succeeded',
'row_count' => $stats['row_count'],
'excluded_account_rows' => $stats['excluded_account_rows'],
'store_count' => $stats['store_count'],
'bill_item_count' => $stats['bill_item_count'],
'account_type_counts' => json_encode($stats['account_type_counts'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
'income_total' => $stats['income_total'],
'expense_total' => $stats['expense_total'],
'net_total' => $stats['net_total'],
'completed_at' => $now,
'last_error_code' => null,
'updated_at' => $now,
]);
});
$this->connection->table(self::STAGING_TABLE)->where('generation', $generation)->delete();
}
/** @return array<int,string> */
private function rowCells(string $xml, array $sharedStrings): array
{
$row = simplexml_load_string($xml);
if (! $row instanceof SimpleXMLElement) {
return [];
}
$cells = [];
foreach ($row->xpath('./*[local-name()="c"]') ?: [] as $cell) {
$reference = (string) ($cell['r'] ?? '');
if (preg_match('/^([A-Z]+)\d+$/i', $reference, $matches) !== 1) {
continue;
}
$type = (string) ($cell['t'] ?? '');
if ($type === 'inlineStr') {
$nodes = $cell->xpath('.//*[local-name()="t"]') ?: [];
$value = implode('', array_map(static fn (SimpleXMLElement $node): string => (string) $node, $nodes));
} else {
$nodes = $cell->xpath('./*[local-name()="v"]') ?: [];
$raw = isset($nodes[0]) ? (string) $nodes[0] : '';
$value = $type === 's' ? (string) ($sharedStrings[(int) $raw] ?? '') : $raw;
}
$cells[$this->columnIndex($matches[1])] = $value;
}
return $cells;
}
/** @param array<int,string> $cells @return array<string,int> */
private function headerMap(array $cells): ?array
{
$lookup = [];
foreach ($cells as $column => $value) {
$lookup[trim($value)] = $column;
}
$mapping = [];
foreach (self::HEADER_ALIASES as $field => $aliases) {
foreach ($aliases as $alias) {
if (array_key_exists($alias, $lookup)) {
$mapping[$field] = $lookup[$alias];
break;
}
}
if (! array_key_exists($field, $mapping)) {
return null;
}
}
return $mapping;
}
private function required(array $values, string $key, int $row, ?string $label = null): string
{
$value = trim((string) ($values[$key] ?? ''));
if ($value === '') {
throw new RuntimeException("第 {$row} 行缺少" . ($label ?? $key) . '。');
}
return $value;
}
private function money(
string $value,
int $row,
string $field,
bool $absolute = false,
bool $allowNegative = false,
): string {
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
$value = $value === '' ? '0' : $value;
if (! is_numeric($value) || (! $allowNegative && ! $absolute && bccomp($value, '0', 6) < 0)) {
throw new RuntimeException("第 {$row} 行{$field}不是有效金额。");
}
if ($absolute && bccomp($value, '0', 6) < 0) {
$value = bcmul($value, '-1', 6);
}
return bcadd($value, '0', 2);
}
private function controlAmount(string $value, string $label): string
{
$value = str_replace([',', ',', '¥', '¥'], '', trim($value));
if (! is_numeric($value) || bccomp($value, '0', 6) < 0) {
throw new RuntimeException("权威{$label}控制数无效。");
}
return bcadd($value, '0', 2);
}
private function dateTime(string $value, int $row, string $field): string
{
$parsed = $this->parseDateTime($value);
if ($parsed === null) {
throw new RuntimeException("第 {$row} 行{$field}不是有效日期时间。");
}
return $parsed;
}
private function optionalDateTime(string $value, int $row, string $field): ?string
{
if (trim($value) === '') {
return null;
}
return $this->dateTime($value, $row, $field);
}
private function parseDateTime(string $value): ?string
{
$value = trim($value);
try {
if (is_numeric($value)) {
$seconds = (int) round((((float) $value) - 25569) * 86400);
return Carbon::createFromTimestampUTC($seconds)->format('Y-m-d H:i:s');
}
return Carbon::parse($value)->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
private function nullable(string $value, int $max = 255): ?string
{
$value = trim($value);
return $value === '' ? null : mb_substr($value, 0, $max);
}
private function columnIndex(string $letters): int
{
$index = 0;
foreach (str_split(strtoupper($letters)) as $letter) {
$index = ($index * 26) + ord($letter) - 64;
}
return $index - 1;
}
/** @return list<string> */
private function sharedStrings(string $path, ZipArchive $archive): array
{
if ($archive->locateName('xl/sharedStrings.xml') === false) {
return [];
}
$reader = new XMLReader();
if (! $reader->open($this->zipUri($path, 'xl/sharedStrings.xml'), null, LIBXML_NONET | LIBXML_COMPACT)) {
throw new RuntimeException('无法读取 XLSX 共享文本。');
}
$strings = [];
try {
while ($reader->read()) {
if ($reader->nodeType !== XMLReader::ELEMENT || $reader->localName !== 'si') {
continue;
}
$item = simplexml_load_string($reader->readOuterXml());
$nodes = $item instanceof SimpleXMLElement ? ($item->xpath('.//*[local-name()="t"]') ?: []) : [];
$strings[] = implode('', array_map(
static fn (SimpleXMLElement $node): string => (string) $node,
$nodes,
));
}
} finally {
$reader->close();
}
return $strings;
}
private function firstWorksheet(ZipArchive $archive): string
{
$worksheets = [];
for ($index = 0; $index < $archive->numFiles; $index++) {
$name = $archive->getNameIndex($index);
if (is_string($name) && preg_match('#^xl/worksheets/sheet\d+\.xml$#', $name) === 1) {
$worksheets[] = $name;
}
}
sort($worksheets, SORT_NATURAL);
if ($worksheets === []) {
throw new RuntimeException('XLSX 文件中没有工作表。');
}
return $worksheets[0];
}
private function zipUri(string $path, string $entry): string
{
return 'zip://' . str_replace('\\', '/', $path) . '#' . $entry;
}
private function result(array $row, string $status): array
{
return [
'status' => $status,
'row_count' => (int) ($row['row_count'] ?? 0),
'excluded_account_rows' => (int) ($row['excluded_account_rows'] ?? 0),
'store_count' => (int) ($row['store_count'] ?? 0),
'bill_item_count' => (int) ($row['bill_item_count'] ?? 0),
'account_type_counts' => $this->accountTypeCounts($row['account_type_counts'] ?? []),
'income_total' => bcadd((string) ($row['income_total'] ?? '0'), '0', 2),
'expense_total' => bcadd((string) ($row['expense_total'] ?? '0'), '0', 2),
'net_total' => bcadd((string) ($row['net_total'] ?? '0'), '0', 2),
'period_start' => (string) ($row['period_start'] ?? ''),
'period_end' => (string) ($row['period_end'] ?? ''),
'source_sha256' => (string) ($row['source_sha256'] ?? ''),
];
}
/** @return array<string,int> */
private function accountTypeCounts(mixed $value): array
{
if (is_string($value)) {
try {
$value = json_decode($value, true, 512, JSON_THROW_ON_ERROR);
} catch (Throwable) {
return [];
}
}
if (! is_array($value)) {
return [];
}
$counts = [];
foreach ($value as $account => $count) {
if (is_string($account) && $account !== '' && is_numeric($count)) {
$counts[$account] = (int) $count;
}
}
return $counts;
}
}

View File

@ -39,6 +39,25 @@ class ProductPriceWorkbookImporter
{
}
/** @return array<string,array{label:string,type:string}> */
public function templateFields(): array
{
$fields = [];
foreach (self::HEADERS as $label => $field) {
if ($field === 'upstream_key') {
continue;
}
$fields[$field] = [
'label' => $label,
'type' => isset(self::TEXT_LIMITS[$field]) ? 'text' : 'number',
];
}
return $fields;
}
/** @return array{created:int,updated:int,rows:int} */
public function import(UploadedFile $file, int $companyId, ?int $userId): array
{

View File

@ -0,0 +1,89 @@
<?php
namespace Modules\EcommerceFinance\Services;
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Border;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierPayableImportTemplate
{
/** @param array<string,array{label:string,type:string}> $fields */
public function download(string $title, array $fields): StreamedResponse
{
return response()->streamDownload(function () use ($title, $fields): void {
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle($this->sheetTitle($title));
$headers = array_merge(['记录标识'], array_column($fields, 'label'));
$sheet->fromArray($headers, null, 'A1');
$lastColumn = Coordinate::stringFromColumnIndex(count($headers));
$headerRange = 'A1:' . $lastColumn . '1';
$sheet->getStyle($headerRange)->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
'alignment' => ['horizontal' => Alignment::HORIZONTAL_CENTER, 'vertical' => Alignment::VERTICAL_CENTER],
'borders' => ['allBorders' => ['borderStyle' => Border::BORDER_THIN, 'color' => ['rgb' => 'D8D2C7']]],
]);
$sheet->getRowDimension(1)->setRowHeight(24);
$sheet->freezePane('A2');
$sheet->setAutoFilter($headerRange);
$sheet->getColumnDimension('A')->setWidth(38);
$columnIndex = 2;
foreach ($fields as $definition) {
$column = Coordinate::stringFromColumnIndex($columnIndex);
$width = max(14, min(30, mb_strlen($definition['label']) * 2 + 4));
$sheet->getColumnDimension($column)->setWidth($width);
if ($definition['type'] === 'number') {
$sheet->getStyle($column . '2:' . $column . '10001')->getNumberFormat()->setFormatCode('0.0000');
} else {
$sheet->getStyle($column . '2:' . $column . '10001')->getNumberFormat()->setFormatCode('@');
}
$columnIndex++;
}
$sheet->getStyle('A2:A10001')->getNumberFormat()->setFormatCode('@');
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['项目', '说明'],
['记录标识', '必填。请从本页面导出的文件中复制,不能修改。'],
['日期', '请填写 YYYY-MM-DD,例如 2026-09-17。'],
['月份', '请填写 YYYY-MM,例如 2026-09。'],
['数字', '最多支持 4 位小数;金额可直接填写数字。'],
['空白单元格', '导入后将清空该字段,请确认后再导入。'],
], null, 'A1');
$instructions->getStyle('A1:B1')->applyFromArray([
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
'fill' => ['fillType' => Fill::FILL_SOLID, 'startColor' => ['rgb' => '856632']],
]);
$instructions->getColumnDimension('A')->setWidth(18);
$instructions->getColumnDimension('B')->setWidth(58);
$instructions->getStyle('A1:B6')->getAlignment()->setVertical(Alignment::VERTICAL_CENTER)->setWrapText(true);
foreach (range(1, 6) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 24 : 32);
}
$spreadsheet->setActiveSheetIndex(0);
(new Xlsx($spreadsheet))->save('php://output');
$spreadsheet->disconnectWorksheets();
}, $title . '_导入模板_' . now()->format('Ymd') . '.xlsx', [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Cache-Control' => 'private, no-store',
]);
}
private function sheetTitle(string $title): string
{
$title = str_replace(['\\', '/', '?', '*', '[', ']', ':'], '', $title) ?: '导入模板';
return mb_substr($title, 0, 31);
}
}

View File

@ -0,0 +1,167 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Validation\ValidationException;
class SupplierPayableMaintenanceRegistry
{
/** @return array<string,array{label:string,type:string}> */
public function fields(string $dataset): array
{
$money = static fn (string $label): array => ['label' => $label, 'type' => 'number'];
$text = static fn (string $label): array => ['label' => $label, 'type' => 'text'];
$date = static fn (string $label): array => ['label' => $label, 'type' => 'date'];
$month = static fn (string $label): array => ['label' => $label, 'type' => 'month'];
return match ($dataset) {
'payment-reconciliation' => [
'supplier' => $text('供应商'), 'skuCode' => $text('货号'), 'productCode' => $text('商品编码'),
'skuName' => $text('规格名称'), 'qty' => $money('数量'), 'unitPrice' => $money('单价'),
'amount' => $money('金额'), 'guessUnitPrice' => $money('预估单价'), 'guessAmount' => $money('预估金额'),
'supplierGoodsCode' => $text('供应商商品编码'), 'remark' => $text('备注'),
'warehouse' => $text('仓库名称'), 'expressName' => $text('快递名称'), 'expressNo' => $text('快递单号'),
],
'return-reconciliation', 'return-unpriced' => [
'send_time' => $date('发货时间'), 'supplier' => $text('明细供应商'), 'barcode' => $text('条形码'),
'sku_name' => $text('商品名称'), 'qty' => $money('发货量'), 'unit_price' => $money('单价'),
'amount' => $money('金额'), 'guess_unit_price' => $money('预估单价'), 'guess_amount' => $money('预估金额'),
'supplier_barcode' => $text('供应商条码'), 'remark' => $text('备注'), 'title' => $text('标题'),
'warehouse' => $text('仓库名称'), 'express_no' => $text('快递单号'),
'express_name' => $text('快递名称'), 'labels' => $text('标记多标签'), 'reconciler' => $text('对账人'),
],
'invoice-deductions' => [
'month_str' => $month('日期'), 'category' => $text('类别'), 'payee' => $text('打款抬头'),
'receiver' => $text('收款方'), 'board_factory' => $text('供板材厂家/代扣厂家'),
'subsidy_fee' => $money('补贴费用'), 'remark' => $text('备注'), 'price' => $money('对私打款金额'),
'detail' => $text('明细'), 'note' => $text('注明'), 'person_charge' => $text('负责人'),
],
'compensation-details' => [
'month' => $month('日期'), 'supplier' => $text('供应商'), 'amount' => $money('赔付金额'),
'reason' => $text('赔付原因'), 'remark' => $text('备注'),
],
'advance-details' => [
'supplier' => $text('供应商'), 'contract_name' => $text('协议签订名称'),
'advance_amount' => $money('借支金额'), 'actual_pay' => $money('实际打款金额'),
'month_deduct' => $money('本月扣款金额'), 'should_deduct' => $money('应扣金额'),
'deduct_start_month' => $month('分期起始扣款月份'), 'deduct_end_month' => $month('分期最后扣款月份'),
'remain_periods' => $money('分期剩余期数'), 'month_str' => $month('借支日期'),
'loan_term' => $text('借款期限'), 'remark' => $text('备注'), 'is_paid' => $text('是否打款'),
'pay_month' => $month('打款月份'), 'serial_no' => $text('流水号'),
'loan_agreement' => $text('借支协议'), 'person_charge' => $text('负责人'),
],
'sample-deductions' => [
'finish_time' => $date('日期'), 'department' => $text('部门'), 'creator' => $text('申请人'),
'sku_id' => $text('商品代码'), 'skuName' => $text('商品名称'), 'num' => $money('数量'),
'supplier' => $text('厂家'), 'purchase' => $text('对应采购'), 'productCode' => $text('扣款厂家商品代码'),
'shipping_fee' => $money('运费'), 'cost_price' => $money('成本价'),
'total_amount' => $money('合计扣款金额'), 'estimated_price' => $money('预估价'),
'estimated_price_all' => $money('预估金额'), 'remark' => $text('备注'),
],
'delivery-unpriced' => [
'transactionNo' => $text('来源单号'), 'distributionNo' => $text('单据编号(采购单号)'),
'date' => $date('创建时间'), 'supplier' => $text('供应商'), 'sku' => $text('货号'),
'barCode' => $text('条码'), 'skuName' => $text('规格名'), 'supplier_barcode' => $text('供应商条码'),
'remark' => $text('备注'), 'warehouse' => $text('仓库'), 'expressName' => $text('快递名称'),
'expressNo' => $text('快递单号'), 'purchase' => $text('负责人'),
],
'other-adjustments' => [
'project' => $text('项目'), 'month_str' => $month('日期'), 'supplier' => $text('厂家'),
'supplement_amount' => $money('补款金额'), 'supplement_reason' => $text('补款原因'),
'remark' => $text('备注'), 'remark2' => $text('备注2'), 'person_charge' => $text('负责人'),
],
'dropshipping-express' => [
'reconciliation_person' => $text('对账人'), 'month_str' => $month('日期'), 'supplier' => $text('供应商'),
'express_freight' => $money('快递运费'), 'express_account' => $money('快递记账'),
'replenish_freight' => $money('补运费'), 'replenish_account' => $money('补记账'),
'return_fee' => $money('退回费'), 'intercept_fee' => $money('拦截'),
'change_address_fee' => $money('改地址'), 'turnover_fee' => $money('翻箱费'),
'overlong_fee' => $money('超长费'), 'total_freight' => $money('合计运费'),
'remark' => $text('备注'), 'express_name' => $text('快递'), 'remark2' => $text('备注2'),
],
'board-deductions' => [
'stock_in_date' => $date('入库日期'), 'stock_out_date' => $date('出库日期'),
'furniture_factory' => $text('家具厂'), 'lamination_factory' => $text('压贴厂'),
'deduction_type' => $text('类型'), 'specification' => $text('规格'), 'material_name' => $text('原料名称'),
'received_quantity' => $money('实收数量'), 'unit_price' => $money('单价'), 'amount' => $money('金额'),
'unit' => $text('单位'), 'batch_date' => $date('批次日期'), 'stock_out_number' => $text('出库单号'),
'stock_out_remark' => $text('出库备注'), 'claim_number' => $text('申领单号'),
'number_type' => $text('单号类型'), 'delivery_number' => $text('送货单号'),
],
'supplier-payables-report' => [
'checker' => $text('对账人'), 'settlement_method' => $text('结算方式'), 'company' => $text('供应商公司主体名称'),
'supplier' => $text('供应商简称'), 'express_freight' => $money('快递运费'),
'beginning_payable' => $money('月初未付'), 'unpriced' => $money('之前未定价-本月定价'),
'advance_loan' => $money('预支借款'), 'loan' => $money('货款金额'), 'refund' => $money('退货金额'),
'procurement' => $money('采购小计'), 'invoice_deduction' => $money('发票扣款'),
'board_deduction' => $money('板材扣款'), 'sample_deduction' => $money('样品扣款'),
'total_compensation' => $money('总赔偿金额'), 'other_adjustment' => $money('其他费用'),
'supplier_supplement' => $money('补给厂家'), 'supplier_balance' => $money('供应商余额'),
'should_pay' => $money('应付余额'), 'estimated_price' => $money('预估金额'),
'to_corporate' => $money('对公打款金额'), 'to_private' => $money('对私打款金额'),
'cashier_paid' => $money('已销账金额'), 'remarks' => $text('备注'),
'private_reason' => $text('对私原因'), 'deposit' => $money('押款金额'),
'negative_reason' => $text('负数原因'),
],
default => throw ValidationException::withMessages(['dataset' => '不支持该供应商应付页面。']),
};
}
public function normalize(string $dataset, array $input): array
{
$fields = $this->fields($dataset);
$values = [];
foreach ($fields as $field => $definition) {
if (! array_key_exists($field, $input)) {
continue;
}
$value = $input[$field];
if ($value === null || trim((string) $value) === '') {
$values[$field] = null;
continue;
}
if ($definition['type'] === 'number') {
$number = str_replace([',', ',', '¥', '¥'], '', trim((string) $value));
if (preg_match('/^-?(?:\d+(?:\.\d{1,4})?|\.\d{1,4})$/', $number) !== 1
|| bccomp(ltrim($number, '-'), '9999999999999999.9999', 4) === 1) {
throw ValidationException::withMessages([
$field => $definition['label'] . '必须是最多 4 位小数的有效数字。',
]);
}
$values[$field] = $number;
continue;
}
$text = trim((string) $value);
if (mb_strlen($text) > 5000) {
throw ValidationException::withMessages([$field => $definition['label'] . '内容过长。']);
}
if ($definition['type'] === 'date' && preg_match('/^\d{4}-\d{2}-\d{2}$/', $text) !== 1) {
throw ValidationException::withMessages([$field => $definition['label'] . '日期格式无效。']);
}
if ($definition['type'] === 'month' && preg_match('/^\d{4}-\d{2}$/', $text) !== 1) {
throw ValidationException::withMessages([$field => $definition['label'] . '月份格式无效。']);
}
$values[$field] = $text;
}
if ($values === []) {
throw ValidationException::withMessages(['values' => '没有可保存的字段。']);
}
return $values;
}
public function publicDefinition(string $dataset): array
{
return [
'dataset' => $dataset,
'fields' => collect($this->fields($dataset))->map(
static fn (array $definition, string $name): array => ['name' => $name] + $definition
)->values()->all(),
];
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Closure;
use Illuminate\Database\ConnectionInterface;
use Illuminate\Support\Facades\DB;
class SupplierPayableOverrideStore
{
public function key(string $dataset, array $row): string
{
if (isset($row['sourceId']) && $row['sourceId'] !== '') {
return 'id:' . (string) ($row['recordType'] ?? '') . ':' . (string) $row['sourceId'];
}
if (isset($row['id']) && $row['id'] !== '') {
return 'id:' . (string) $row['id'];
}
$immutable = $row;
unset($immutable['_action_key'], $immutable['_overridden']);
return 'hash:' . hash('sha256', json_encode($immutable, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
}
/** @param array<int,array<string,mixed>> $rows */
public function decorate(string $dataset, array $rows, int $companyId): array
{
if ($rows === []) {
return [];
}
$keys = [];
foreach ($rows as $row) {
$keys[] = $this->key($dataset, $row);
}
$overrides = $this->connection()->table('supplier_payable_overrides')
->where('company_id', $companyId)
->where('dataset', $dataset)
->whereIn('record_key', $keys)
->get()
->keyBy('record_key');
foreach ($rows as $index => &$row) {
$key = $keys[$index];
$row['_action_key'] = $key;
$override = $overrides->get($key);
if ($override) {
$values = json_decode((string) $override->values, true) ?: [];
$row = array_merge($row, $values);
$row['_overridden'] = true;
} else {
$row['_overridden'] = false;
}
}
unset($row);
return $rows;
}
/** @return array{created:int,updated:int} */
public function save(string $dataset, string $recordKey, array $values, int $companyId, ?int $userId, string $source = 'manual'): array
{
$connection = $this->connection();
$existing = $connection->table('supplier_payable_overrides')
->where(['company_id' => $companyId, 'dataset' => $dataset, 'record_key' => $recordKey])
->first();
$before = $existing ? (json_decode((string) $existing->values, true) ?: []) : null;
$now = now();
$connection->transaction(function () use ($connection, $existing, $dataset, $recordKey, $values, $companyId, $userId, $now, $source, $before): void {
$connection->table('supplier_payable_overrides')->updateOrInsert(
['company_id' => $companyId, 'dataset' => $dataset, 'record_key' => $recordKey],
[
'values' => json_encode($values, JSON_UNESCAPED_UNICODE),
'updated_by' => $userId,
'created_by' => $existing->created_by ?? $userId,
'created_at' => $existing->created_at ?? $now,
'updated_at' => $now,
],
);
$connection->table('supplier_payable_override_events')->insert([
'company_id' => $companyId,
'dataset' => $dataset,
'record_key' => $recordKey,
'source' => $source,
'before_values' => $before === null ? null : json_encode($before, JSON_UNESCAPED_UNICODE),
'after_values' => json_encode($values, JSON_UNESCAPED_UNICODE),
'user_id' => $userId,
'created_at' => $now,
]);
});
return ['created' => $existing ? 0 : 1, 'updated' => $existing ? 1 : 0];
}
public function transaction(Closure $callback): mixed
{
return $this->connection()->transaction($callback);
}
private function connection(): ConnectionInterface
{
return DB::connection(config('database.supplier_payable_maintenance_connection'));
}
}

View File

@ -0,0 +1,129 @@
<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
class SupplierPayableWorkbookImporter
{
public function __construct(
private readonly SupplierPayableMaintenanceRegistry $registry,
private readonly SupplierPayableOverrideStore $overrides,
) {
}
/** @return array{created:int,updated:int,rows:int} */
public function import(string $dataset, UploadedFile $file, int $companyId, ?int $userId): array
{
$definitions = $this->registry->fields($dataset);
$lookup = ['记录标识' => '_action_key', 'recordkey' => '_action_key', '_action_key' => '_action_key'];
foreach ($definitions as $field => $definition) {
$lookup[$this->header($field)] = $field;
$lookup[$this->header($definition['label'])] = $field;
}
try {
$readerType = match (mb_strtolower($file->getClientOriginalExtension())) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
default => throw new \RuntimeException('Unsupported workbook format.'),
};
$spreadsheet = IOFactory::createReader($readerType)->load($file->getRealPath());
[$rows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup);
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
} finally {
if (isset($spreadsheet)) {
$spreadsheet->disconnectWorksheets();
}
}
if ($rows === null || ! in_array('_action_key', $columns, true)) {
throw ValidationException::withMessages([
'import' => '未找到“记录标识”列,请先导出当前数据,再使用导出的表格修改。',
]);
}
$records = [];
foreach (array_slice($rows, $headerRow + 1, null, true) as $rowIndex => $row) {
$line = $rowIndex + 1;
$key = '';
$rawValues = [];
foreach ($columns as $columnIndex => $field) {
if ($field === '_action_key') {
$key = trim((string) ($row[$columnIndex] ?? ''));
} else {
$rawValues[$field] = $row[$columnIndex] ?? null;
}
}
if ($key === '' && collect($rawValues)->every(static fn (mixed $value): bool => trim((string) $value) === '')) {
continue;
}
if (! str_starts_with($key, 'id:') && ! preg_match('/^hash:[a-f0-9]{64}$/', $key)) {
throw ValidationException::withMessages(['import' => "第 {$line} 行“记录标识”无效,请勿修改该列。"]);
}
if (isset($records[$key])) {
throw ValidationException::withMessages(['import' => "第 {$line} 行“记录标识”重复。"]);
}
try {
$records[$key] = $this->registry->normalize($dataset, $rawValues);
} catch (ValidationException $exception) {
throw ValidationException::withMessages([
'import' => "第 {$line} 行:" . collect($exception->errors())->flatten()->first(),
]);
}
if (count($records) > 10000) {
throw ValidationException::withMessages(['import' => '单次最多导入 10,000 条记录,请拆分文件后重试。']);
}
}
if ($records === []) {
throw ValidationException::withMessages(['import' => '表格中没有可导入的数据行。']);
}
$counts = ['created' => 0, 'updated' => 0];
$this->overrides->transaction(function () use ($records, $dataset, $companyId, $userId, &$counts): void {
foreach ($records as $key => $values) {
$result = $this->overrides->save($dataset, $key, $values, $companyId, $userId, 'import');
$counts['created'] += $result['created'];
$counts['updated'] += $result['updated'];
}
});
return $counts + ['rows' => count($records)];
}
private function bestSheet(mixed $spreadsheet, array $lookup): array
{
$best = [null, null, []];
$score = 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->header($heading);
if ($key !== '' && isset($lookup[$key])) {
$columns[$columnIndex] = $lookup[$key];
}
}
if (count($columns) > $score) {
$best = [$rows, $rowIndex, $columns];
$score = count($columns);
}
}
}
return $best;
}
private function header(mixed $value): string
{
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()]+/u', '', trim((string) $value)));
}
}

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
@ -21,10 +22,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#advance-detail-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'advance-details',
baseUrl: url + '/workspace-data/supplier-payables/advance-details',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
@ -21,10 +22,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#compensation-detail-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'compensation-details',
baseUrl: url + '/workspace-data/supplier-payables/compensation-details',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Select, Option, Message
@ -23,10 +24,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#delivery-unpriced-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'delivery-unpriced',
baseUrl: url + '/workspace-data/supplier-payables/delivery-unpriced',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Select, Option, Message
@ -23,10 +24,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#dropshipping-express-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'dropshipping-express',
baseUrl: url + '/workspace-data/supplier-payables/dropshipping-express',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table,
@ -24,10 +25,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#goods-reconciliation-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'payment-reconciliation',
baseUrl: url + '/workspace-data/supplier-payables/payment-reconciliation',
loading: false,
tableData: [],
@ -234,6 +236,17 @@ const app = new Vue({
this.fetchData();
},
onReset() {
this.filters = {
record_type: 'all', supplier: '', warehouse: '', stock_no: '', purchase_no: '',
product_code: '', express_no: '', send_time_range: null, create_time_range: null,
sent_status: 'all', has_diff: false
};
this.activeTab = 'all';
this.currentPage = 1;
this.fetchData();
},
/**
* Pagination page change
*/

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
@ -21,10 +22,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#invoice-deduction-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'invoice-deductions',
baseUrl: url + '/workspace-data/supplier-payables/invoice-deductions',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
@ -21,10 +22,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#other-adjustment-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'other-adjustments',
baseUrl: url + '/workspace-data/supplier-payables/other-adjustments',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table,
@ -32,10 +33,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#return-reconciliation-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'return-reconciliation',
baseUrl: url + '/workspace-data/supplier-payables/return-reconciliation',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Select, Option, Message
@ -23,10 +24,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#return-unpriced-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'return-unpriced',
baseUrl: url + '/workspace-data/supplier-payables/return-unpriced',
loading: false,
tableData: [],

View File

@ -3,6 +3,7 @@ require('./../../bootstrap');
import Vue from 'vue';
import DashboardPlugin from './../../plugins/dashboard-plugin';
import Global from './../../mixins/global';
import SupplierPayableMaintenance from './supplier-payable-maintenance';
import {
Table, TableColumn, Pagination, DatePicker, Tag, Button, Input, Message
@ -21,10 +22,11 @@ Vue.prototype.$message = Message;
const app = new Vue({
el: '#sample-deduction-app',
mixins: [Global],
mixins: [Global, SupplierPayableMaintenance],
data() {
return {
maintenanceDataset: 'sample-deductions',
baseUrl: url + '/workspace-data/supplier-payables/sample-deductions',
loading: false,
tableData: [],

View File

@ -0,0 +1,166 @@
import Vue from 'vue';
import { Dialog, Form, FormItem, Input, DatePicker, Button } from 'element-ui';
[Dialog, Form, FormItem, Input, DatePicker, Button].forEach(component => Vue.component(component.name, component));
export default {
data() {
return {
maintenance: {
fields: [],
form: {},
row: null,
selectedRow: null,
visible: false,
saving: false,
importing: false,
importVisible: false,
importFile: null,
importFileName: ''
}
};
},
computed: {
maintenanceBaseUrl() {
return url + '/workspace/supplier-payables/actions/' + this.maintenanceDataset;
}
},
watch: {
tableData() {
this.maintenanceSelect(null);
}
},
mounted() {
axios.get(this.maintenanceBaseUrl + '/definition')
.then(response => { this.maintenance.fields = response.data.fields || []; })
.catch(() => this.$message.error('获取维护字段失败'));
},
methods: {
maintenanceSelect(row) {
this.maintenance.selectedRow = row || null;
},
maintenanceEditSelected() {
const row = this.maintenance.selectedRow;
if (!row || !row._action_key) {
this.$message.warning('请先选择一条记录');
return;
}
if (!this.maintenance.fields.length) {
this.$message.warning('编辑字段正在加载,请稍后重试');
return;
}
this.maintenanceOpen(row);
},
maintenanceOpen(row) {
if (!row || !row._action_key || !this.maintenance.fields.length) return;
const form = {};
this.maintenance.fields.forEach(field => {
const value = row[field.name];
form[field.name] = value == null ? '' : String(value);
});
this.maintenance.row = row;
this.maintenance.form = form;
this.maintenance.visible = true;
},
maintenanceClose() {
this.maintenance.row = null;
this.maintenance.form = {};
this.maintenance.visible = false;
this.maintenance.saving = false;
},
maintenanceSave() {
if (!this.maintenance.row || !this.maintenance.row._action_key) {
this.$message.error('当前记录缺少维护标识,请刷新后重试');
return;
}
this.maintenance.saving = true;
axios.patch(
this.maintenanceBaseUrl + '/' + this.maintenanceRecordKey(this.maintenance.row._action_key),
{ values: this.maintenance.form },
{ headers: { Accept: 'application/json', 'Content-Type': 'application/json' } }
).then(response => {
this.$message.success(response.data.message || '保存成功');
this.maintenance.visible = false;
this.fetchData();
}).catch(error => this.$message.error(this.maintenanceError(error, '保存失败')))
.finally(() => { this.maintenance.saving = false; });
},
maintenanceRecordKey(recordKey) {
return String(recordKey).split(':').map(encodeURIComponent).join(':');
},
maintenanceOpenImport() {
this.maintenance.importFile = null;
this.maintenance.importFileName = '';
this.maintenance.importVisible = true;
},
maintenanceCloseImport() {
if (this.maintenance.importing) return;
this.maintenance.importVisible = false;
this.maintenance.importFile = null;
this.maintenance.importFileName = '';
if (this.$refs.maintenanceImport) this.$refs.maintenanceImport.value = '';
},
maintenanceChooseImport() {
this.$refs.maintenanceImport.click();
},
maintenanceSelectImport(event) {
const file = event.target.files && event.target.files[0];
if (!file) return;
this.maintenance.importFile = file;
this.maintenance.importFileName = file.name;
},
maintenanceImport() {
const file = this.maintenance.importFile;
if (!file) {
this.$message.warning('请先选择导入文件');
return;
}
const body = new FormData();
body.append('import', file);
this.maintenance.importing = true;
axios.post(this.maintenanceBaseUrl + '/import', body, {
headers: { Accept: 'application/json', 'Content-Type': 'multipart/form-data' }
}).then(response => {
this.$message.success(response.data.message || '导入完成');
this.maintenance.importVisible = false;
this.maintenance.importFile = null;
this.maintenance.importFileName = '';
if (this.$refs.maintenanceImport) this.$refs.maintenanceImport.value = '';
this.fetchData();
}).catch(error => this.$message.error(this.maintenanceError(error, '导入失败')))
.finally(() => { this.maintenance.importing = false; });
},
maintenanceExport() {
const params = Object.assign({}, this.buildParams ? this.buildParams() : {});
delete params.page;
delete params.limit;
const query = new URLSearchParams(params).toString();
window.location.href = this.maintenanceBaseUrl + '/export' + (query ? '?' + query : '');
},
maintenanceError(error, fallback) {
const errors = error && error.response && error.response.data && error.response.data.errors;
if (errors) {
const first = Object.values(errors)[0];
if (Array.isArray(first) && first[0]) return first[0];
}
return (error && error.response && error.response.data && error.response.data.message) || fallback;
}
}
};

View File

@ -6,7 +6,7 @@
</div>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">借支总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
@ -25,8 +25,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -35,7 +34,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="140" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column label="协议签订名称" width="140">

View File

@ -3,6 +3,7 @@
$boardDeductionFilters = $content['boardDeductionFilters'] ?? [];
$boardDeductionError = $content['boardDeductionError'] ?? null;
$boardDeductionStatus = data_get($content, 'boardDeductionStatus.X-Caiwu-Data-Status');
$boardActions = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/board-deductions');
@endphp
<div class="supplier-board-page">
@ -53,6 +54,7 @@
.supplier-board-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@ -78,6 +80,8 @@
color: #ffffff;
}
.supplier-board-button .material-icons-outlined { font-size: 1.125rem; }
.supplier-board-alert {
margin-bottom: 1rem;
padding: 0.75rem 1rem;
@ -189,17 +193,31 @@
<label for="board-deduction-claim-number">申领单号</label>
<input id="board-deduction-claim-number" name="claim_number" type="text" value="{{ $boardDeductionFilters['claim_number'] ?? '' }}" placeholder="请输入申领单号">
</div>
<div class="supplier-board-actions">
<button type="submit" class="supplier-board-button is-primary">
<div class="supplier-board-actions supplier-page-actions">
<button type="button" class="supplier-board-button supplier-action-button" data-supplier-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
</button>
<a href="{{ $boardActions . '/export?' . http_build_query(request()->except('page')) }}" class="supplier-board-button supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
</a>
<button type="submit" class="supplier-board-button supplier-action-button is-primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="supplier-board-button">
<a href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}" class="supplier-board-button supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<button type="button" class="supplier-board-button supplier-action-button" data-supplier-maintenance-open>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
<span>编辑</span>
</button>
</div>
</form>
@include('business.pages.supplier-payables.maintenance-import-modal', [
'supplierImportAction' => $boardActions . '/import',
'supplierTemplateUrl' => $boardActions . '/template',
])
@if ($boardDeductionError)
<div class="supplier-board-alert" role="alert">{{ $boardDeductionError }}</div>
@ -231,7 +249,7 @@
</thead>
<tbody>
@forelse ($boardDeductions ?? [] as $row)
<tr>
<tr data-supplier-maintenance-row data-key="{{ $row->_action_key }}" data-row="{!! e(json_encode($row, JSON_UNESCAPED_UNICODE)) !!}" tabindex="0" aria-selected="false">
<td>{{ $row->stock_in_date ?: '-' }}</td>
<td>{{ $row->stock_out_date ?: '-' }}</td>
<td>{{ $row->furniture_factory ?: '-' }}</td>
@ -277,3 +295,4 @@
</div>
</div>
</div>
@include('business.pages.supplier-payables.maintenance-blade-modal')

View File

@ -46,8 +46,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -56,7 +55,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column prop="month" label="日期" width="100" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="供应商" width="260" show-overflow-tooltip></el-table-column>
<el-table-column prop="amount" label="赔付金额" width="140" align="right">

View File

@ -1,7 +1,7 @@
<div id="delivery-unpriced-app" class="delivery-unpriced-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">未定价记录数</div>
<div class="text-2xl font-bold text-orange-500">@{{ (summary.totalCount || 0).toLocaleString() }}</div>
@ -29,8 +29,7 @@
start-placeholder="日期开始" end-placeholder="日期结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -39,7 +38,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column prop="transactionNo" label="来源单号" width="180" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column prop="distributionNo" label="单据编号(采购单号)" width="180" show-overflow-tooltip></el-table-column>
<el-table-column prop="date" label="创建时间" width="110"></el-table-column>

View File

@ -1,7 +1,7 @@
<div id="dropshipping-express-app" class="dropshipping-express-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">合计运费</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
@ -23,8 +23,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -33,7 +32,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="reconciliation_person" label="对账人" width="100" fixed="left"></el-table-column>
<el-table-column prop="month_str" label="日期" width="100"></el-table-column>

View File

@ -1,7 +1,7 @@
<div id="invoice-deduction-app" class="invoice-deduction-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">扣款总金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalPrice) }}</div>
@ -20,8 +20,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -30,7 +29,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="month_str" label="日期" width="100" fixed="left"></el-table-column>
<el-table-column label="类别" width="100">

View File

@ -0,0 +1,10 @@
<div class="supplier-page-actions">
@include('business.pages.supplier-payables.maintenance-toolbar')
<el-button size="small" type="primary" class="supplier-action-button is-primary" @click="onFilterChange">
<span class="material-icons-outlined" aria-hidden="true">search</span><span>查询</span>
</el-button>
<el-button size="small" class="supplier-action-button" @click="onReset">
<span class="material-icons-outlined" aria-hidden="true">refresh</span><span>重置</span>
</el-button>
@include('business.pages.supplier-payables.maintenance-edit-button')
</div>

View File

@ -0,0 +1,109 @@
@php
$maintenance = $content['supplierPayableMaintenance'] ?? ['dataset' => '', 'fields' => []];
$maintenanceAction = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/' . $maintenance['dataset']);
@endphp
<div class="supplier-maintenance-modal" data-supplier-maintenance-modal hidden>
<div class="supplier-maintenance-modal__backdrop" data-supplier-maintenance-close></div>
<section class="supplier-maintenance-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-maintenance-modal-title">
<header class="supplier-maintenance-modal__header">
<h2 id="supplier-maintenance-modal-title">编辑记录</h2>
<button type="button" data-supplier-maintenance-close aria-label="关闭" title="关闭"><span class="material-icons-outlined">close</span></button>
</header>
<form method="POST" data-supplier-maintenance-form>
@csrf
@method('PATCH')
<div class="supplier-maintenance-modal__grid">
@foreach ($maintenance['fields'] as $field)
<label>
<span>{{ $field['label'] }}</span>
<input name="values[{{ $field['name'] }}]" data-maintenance-field="{{ $field['name'] }}"
type="{{ $field['type'] === 'number' ? 'number' : ($field['type'] === 'date' ? 'date' : ($field['type'] === 'month' ? 'month' : 'text')) }}"
@if ($field['type'] === 'number') step="0.0001" @endif>
</label>
@endforeach
</div>
<footer>
<button type="button" class="supplier-maintenance-button" data-supplier-maintenance-close>取消</button>
<button type="submit" class="supplier-maintenance-button is-primary">保存</button>
</footer>
</form>
</section>
</div>
@once
<style>
.supplier-maintenance-modal[hidden] { display: none; }
.supplier-maintenance-modal { position: fixed; inset: 0; z-index: 1050; display: grid; place-items: center; padding: 1rem; }
.supplier-maintenance-modal__backdrop { position: absolute; inset: 0; background: rgba(5, 5, 6, .58); }
.supplier-maintenance-modal__dialog { position: relative; width: min(760px, 94vw); max-height: 88vh; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, .10); }
.supplier-maintenance-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #D8D2C7; }
.supplier-maintenance-modal__header h2 { margin: 0; color: #171717; font-size: .875rem; font-weight: 600; }
.supplier-maintenance-modal__header button { border: 0; background: transparent; color: #595959; }
.supplier-maintenance-modal__grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .75rem 1rem; max-height: 62vh; overflow-y: auto; padding: 1rem; }
.supplier-maintenance-modal__grid label { display: flex; flex-direction: column; gap: .35rem; color: #595959; font-size: .75rem; font-weight: 600; }
.supplier-maintenance-modal__grid input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; color: #171717; font-size: .8125rem; letter-spacing: 0; }
.supplier-maintenance-modal__grid input:focus { border-color: #856632; outline: 0; box-shadow: 0 0 0 3px rgba(133, 102, 50, .20); }
.supplier-maintenance-modal footer { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem; border-top: 1px solid #D8D2C7; }
.supplier-maintenance-button { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font-size: .8125rem; font-weight: 500; }
.supplier-maintenance-button.is-primary { border-color: #856632; background: #856632; color: #fff; }
[data-supplier-maintenance-row] { cursor: pointer; }
[data-supplier-maintenance-row].supplier-maintenance-row-selected { background: #F7F0DE; outline: 2px solid #856632; outline-offset: -2px; }
@media (max-width: 640px) { .supplier-maintenance-modal__grid { grid-template-columns: 1fr; } }
</style>
@endonce
@push('body_end')
<script>
(() => {
const modal = document.querySelector('[data-supplier-maintenance-modal]');
if (!modal || modal.dataset.ready) return;
modal.dataset.ready = '1';
const form = modal.querySelector('[data-supplier-maintenance-form]');
const openButton = document.querySelector('[data-supplier-maintenance-open]');
const rows = document.querySelectorAll('[data-supplier-maintenance-row]');
const base = @json($maintenanceAction);
let selectedRow = null;
let returnFocus = null;
const close = () => {
modal.hidden = true;
form.action = '';
form.reset();
returnFocus?.focus();
};
const selectRow = row => {
selectedRow?.classList.remove('supplier-maintenance-row-selected');
selectedRow?.setAttribute('aria-selected', 'false');
selectedRow = row;
selectedRow?.classList.add('supplier-maintenance-row-selected');
selectedRow?.setAttribute('aria-selected', 'true');
};
const open = () => {
if (!selectedRow || !selectedRow.dataset.key) {
window.alert('请先选择一条记录');
return;
}
const row = JSON.parse(selectedRow.dataset.row || '{}');
const recordKey = String(selectedRow.dataset.key).split(':').map(encodeURIComponent).join(':');
form.action = base + '/' + recordKey;
modal.querySelectorAll('[data-maintenance-field]').forEach(input => {
const value = row[input.dataset.maintenanceField];
input.value = value == null ? '' : value;
});
returnFocus = openButton;
modal.hidden = false;
};
rows.forEach(row => {
row.addEventListener('click', () => selectRow(row));
row.addEventListener('keydown', event => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
selectRow(row);
});
});
openButton?.addEventListener('click', open);
modal.querySelectorAll('[data-supplier-maintenance-close]').forEach(node => node.addEventListener('click', close));
document.addEventListener('keydown', event => { if (event.key === 'Escape') close(); });
})();
</script>
@endpush

View File

@ -0,0 +1,6 @@
<el-button
size="small"
class="supplier-action-button"
@click="maintenanceEditSelected">
<span class="material-icons-outlined" aria-hidden="true">edit</span><span>编辑</span>
</el-button>

View File

@ -0,0 +1,96 @@
<div class="supplier-maintenance-modal supplier-import-modal" data-supplier-import-modal hidden>
<div class="supplier-maintenance-modal__backdrop" data-supplier-import-close></div>
<section class="supplier-maintenance-modal__dialog supplier-import-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="supplier-import-modal-title">
<header class="supplier-maintenance-modal__header">
<h2 id="supplier-import-modal-title">导入数据</h2>
<button type="button" data-supplier-import-close aria-label="关闭" title="关闭"><span class="material-icons-outlined">close</span></button>
</header>
<form method="POST" enctype="multipart/form-data" action="{{ $supplierImportAction }}" data-supplier-import-form>
@csrf
<div class="supplier-import-dialog">
<a href="{{ $supplierTemplateUrl }}" class="supplier-import-template-link">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span><strong>下载导入模板</strong><small>当前页面字段模板(Excel)</small></span>
</a>
<input type="file" name="import" accept=".xlsx,.xls,.csv" data-supplier-import-file hidden required>
<button type="button" class="supplier-import-file-button" data-supplier-import-choose>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span data-supplier-import-file-name>选择导入文件</span>
</button>
</div>
<footer>
<button type="button" class="supplier-maintenance-button" data-supplier-import-close>取消</button>
<button type="submit" class="supplier-maintenance-button is-primary" data-supplier-import-submit disabled>开始导入</button>
</footer>
</form>
</section>
</div>
@once
<style>
.supplier-import-modal[hidden] { display: none; }
.supplier-import-modal { position: fixed; inset: 0; z-index: 1060; display: grid; place-items: center; padding: 1rem; }
.supplier-import-modal .supplier-maintenance-modal__backdrop { position: absolute; inset: 0; background: rgba(5, 5, 6, .58); }
.supplier-import-modal__dialog { position: relative; width: min(520px, 94vw); overflow: hidden; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; box-shadow: 0 4px 8px rgba(23, 23, 23, .10); }
.supplier-import-modal .supplier-maintenance-modal__header { display: flex; align-items: center; justify-content: space-between; padding: 1rem; border-bottom: 1px solid #D8D2C7; }
.supplier-import-modal .supplier-maintenance-modal__header h2 { margin: 0; color: #171717; font-size: .875rem; font-weight: 600; }
.supplier-import-modal .supplier-maintenance-modal__header button { border: 0; background: transparent; color: #595959; cursor: pointer; }
.supplier-import-modal .supplier-import-dialog { padding: 1rem; }
.supplier-import-modal footer { display: flex; justify-content: flex-end; gap: .5rem; padding: 1rem; border-top: 1px solid #D8D2C7; }
.supplier-import-modal .supplier-maintenance-button { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font-size: .8125rem; font-weight: 500; cursor: pointer; }
.supplier-import-modal .supplier-maintenance-button.is-primary { border-color: #856632; background: #856632; color: #fff; }
.supplier-import-modal [data-supplier-import-submit]:disabled { cursor: not-allowed; opacity: .5; }
</style>
@endonce
@push('body_end')
<script>
(() => {
const modal = document.querySelector('[data-supplier-import-modal]');
if (!modal || modal.dataset.ready) return;
modal.dataset.ready = '1';
const form = modal.querySelector('[data-supplier-import-form]');
const input = modal.querySelector('[data-supplier-import-file]');
const fileName = modal.querySelector('[data-supplier-import-file-name]');
const submit = modal.querySelector('[data-supplier-import-submit]');
const openButton = document.querySelector('[data-supplier-import-open]');
let returnFocus = null;
const reset = () => {
form.reset();
fileName.textContent = '选择导入文件';
submit.disabled = true;
submit.textContent = '开始导入';
delete form.dataset.submitting;
};
const close = () => {
if (form.dataset.submitting) return;
modal.hidden = true;
reset();
returnFocus?.focus();
};
openButton?.addEventListener('click', () => {
returnFocus = openButton;
modal.hidden = false;
});
modal.querySelector('[data-supplier-import-choose]')?.addEventListener('click', () => input.click());
input?.addEventListener('change', () => {
const file = input.files?.[0];
fileName.textContent = file?.name || '选择导入文件';
submit.disabled = !file;
});
form?.addEventListener('submit', event => {
if (!input.files?.length) {
event.preventDefault();
return;
}
form.dataset.submitting = '1';
submit.disabled = true;
submit.textContent = '导入中...';
});
modal.querySelectorAll('[data-supplier-import-close]').forEach(node => node.addEventListener('click', close));
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !modal.hidden) close();
});
})();
</script>
@endpush

View File

@ -0,0 +1,43 @@
<el-button size="small" class="supplier-action-button" :loading="maintenance.importing" @click="maintenanceOpenImport">
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
</el-button>
<el-button size="small" class="supplier-action-button" @click="maintenanceExport">
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
</el-button>
<el-dialog title="导入数据" :visible.sync="maintenance.importVisible" width="min(520px, 92vw)" append-to-body :close-on-click-modal="!maintenance.importing" @closed="maintenanceCloseImport">
<div class="supplier-import-dialog">
<a :href="maintenanceBaseUrl + '/template'" class="supplier-import-template-link">
<span class="material-icons-outlined" aria-hidden="true">download</span>
<span><strong>下载导入模板</strong><small>当前页面字段模板(Excel)</small></span>
</a>
<input ref="maintenanceImport" type="file" accept=".xlsx,.xls,.csv" class="supplier-maintenance-file" hidden @change="maintenanceSelectImport">
<button type="button" class="supplier-import-file-button" @click="maintenanceChooseImport">
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
<span>@{{ maintenance.importFileName || '选择导入文件' }}</span>
</button>
</div>
<span slot="footer">
<el-button :disabled="maintenance.importing" @click="maintenanceCloseImport">取消</el-button>
<el-button type="primary" :disabled="!maintenance.importFile" :loading="maintenance.importing" @click="maintenanceImport">开始导入</el-button>
</span>
</el-dialog>
<el-dialog v-if="maintenance.row" title="编辑记录" :visible.sync="maintenance.visible" width="min(760px, 92vw)" append-to-body @closed="maintenanceClose">
<el-form label-position="top" class="supplier-maintenance-form">
<el-form-item v-for="field in maintenance.fields" :key="field.name" :label="field.label">
<el-date-picker
v-if="field.type === 'date' || field.type === 'month'"
v-model="maintenance.form[field.name]"
:type="field.type"
:value-format="field.type === 'month' ? 'yyyy-MM' : 'yyyy-MM-dd'"
style="width: 100%">
</el-date-picker>
<el-input v-else v-model="maintenance.form[field.name]" :type="field.type === 'number' ? 'number' : 'text'"></el-input>
</el-form-item>
</el-form>
<span slot="footer">
<el-button @click="maintenanceClose">取消</el-button>
<el-button type="primary" :loading="maintenance.saving" @click="maintenanceSave">保存</el-button>
</span>
</el-dialog>

View File

@ -33,5 +33,106 @@
.supplier-page-navigation { display:flex; flex-wrap:wrap; gap:4px 16px; margin-bottom:16px; }
.supplier-page-navigation .business-tab-link { flex:0 0 auto; padding:10px 0; font-size:0.75rem; color:var(--vip-content-muted, #595959); }
.supplier-page-navigation .business-tab-link.is-active { color:var(--vip-content-accent-dark, #856632); }
.supplier-maintenance-file { display: none; }
.supplier-maintenance-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 1rem;
max-height: 60vh;
overflow-y: auto;
padding-right: 0.25rem;
}
.supplier-maintenance-form .el-form-item { margin-bottom: 0.75rem; }
.supplier-maintenance-form .el-form-item__label { padding: 0; color: #595959; font-size: 0.75rem; }
.supplier-maintenance-form input { letter-spacing: 0; }
.supplier-maintenance-form input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133, 102, 50, .20); }
.supplier-maintenance-form + * .el-button--primary,
.el-dialog__footer .el-button--primary { border-color: #856632; background: #856632; }
.supplier-import-dialog { display: grid; gap: 12px; }
.supplier-import-template-link,
.supplier-import-file-button {
display: flex;
width: 100%;
min-height: 64px;
box-sizing: border-box;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid #D8D2C7;
border-radius: 8px;
background: #fff;
color: #171717;
text-align: left;
text-decoration: none;
cursor: pointer;
}
.supplier-import-template-link:hover,
.supplier-import-template-link:focus,
.supplier-import-file-button:hover,
.supplier-import-file-button:focus { border-color: #856632; background: #F7F0DE; color: #856632; outline: 0; }
.supplier-import-template-link .material-icons-outlined,
.supplier-import-file-button .material-icons-outlined { flex: 0 0 auto; font-size: 22px; }
.supplier-import-template-link > span:last-child { display: grid; gap: 3px; }
.supplier-import-template-link strong { font-size: .875rem; font-weight: 600; }
.supplier-import-template-link small { color: #737373; font-size: .75rem; }
.supplier-import-file-button > span:last-child { min-width: 0; overflow: hidden; font-size: .8125rem; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
.supplier-page-actions {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.supplier-page-actions .supplier-action-button {
display: inline-flex;
box-sizing: border-box;
min-width: 82px;
height: 36px;
margin: 0;
padding: 0 14px;
align-items: center;
justify-content: center;
gap: 6px;
border: 1px solid var(--vip-border, #D8D2C7);
border-radius: 8px;
background: #fff;
color: #171717;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1;
letter-spacing: 0;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
}
.supplier-page-actions .supplier-action-button > span,
.supplier-page-actions .supplier-action-button.el-button > span {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.supplier-page-actions .supplier-action-button .material-icons-outlined {
font-size: 18px;
line-height: 1;
}
.supplier-page-actions .supplier-action-button:hover,
.supplier-page-actions .supplier-action-button:focus {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-soft, #F7F0DE);
color: var(--vip-content-accent-dark, #856632);
outline: 0;
}
.supplier-page-actions .supplier-action-button.is-primary {
border-color: var(--vip-content-accent-dark, #856632);
background: var(--vip-content-accent-dark, #856632);
color: #fff;
}
.supplier-page-actions .supplier-action-button.is-primary:hover,
.supplier-page-actions .supplier-action-button.is-primary:focus {
border-color: #6f5428;
background: #6f5428;
color: #fff;
}
@media (max-width: 640px) { .supplier-maintenance-form { grid-template-columns: 1fr; } }
</style>
@include('business.pages.supplier-payables.local-navigation')

View File

@ -1,7 +1,7 @@
<div id="other-adjustment-app" class="other-adjustment-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">补款总金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
@ -20,8 +20,7 @@
start-placeholder="月份开始" end-placeholder="月份结束" size="small"
style="width: 240px" value-format="yyyy-MM" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -30,7 +29,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="project" label="项目" width="140" fixed="left">
<template slot-scope="scope">

View File

@ -1,7 +1,7 @@
<div id="goods-reconciliation-app" class="goods-reconciliation-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-4 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
@ -53,6 +53,7 @@
<el-option label="已发出" value="sent"></el-option>
<el-option label="未发出" value="unsent"></el-option>
</el-select>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -71,7 +72,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small"
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small"
:default-sort="{prop: 'createTime', order: 'descending'}"
@sort-change="onSortChange">
<el-table-column type="index" label="#" width="55" fixed="left"></el-table-column>

View File

@ -94,8 +94,7 @@
gap: 8px;
}
.product-price-actions .material-icons-outlined,
.product-price-edit-button .material-icons-outlined {
.product-price-actions .material-icons-outlined {
font-size: 16px;
}
@ -108,24 +107,6 @@
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;
@ -211,6 +192,16 @@
background: var(--vip-content-accent-soft, #F7F0DE);
}
.data-table tbody tr[data-product-price-row] {
cursor: pointer;
}
.data-table tbody tr.product-price-row-selected {
background: var(--vip-content-accent-soft, #F7F0DE);
outline: 2px solid var(--vip-content-accent-dark, #856632);
outline-offset: -2px;
}
.data-table tbody td {
padding: 10px 12px;
color: #171717;
@ -442,50 +433,41 @@
</div>
<div class="filter-group">
<label class="filter-label">&nbsp;</label>
<div class="product-price-actions">
<label for="product-price-import" class="btn-filter btn-filter-secondary" role="button" tabindex="0">
<div class="product-price-actions supplier-page-actions">
<button type="button" class="btn-filter btn-filter-secondary supplier-action-button" data-supplier-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span>
导入
</label>
</button>
<a
href="{{ route('business.supplier-payables.product-prices.export', array_merge(['company_id' => company_id()], request()->except('page'))) }}"
class="btn-filter btn-filter-secondary"
class="btn-filter btn-filter-secondary supplier-action-button"
aria-label="导出当前筛选结果"
title="导出当前筛选结果"
>
<span class="material-icons-outlined" aria-hidden="true">download</span>
导出
</a>
<button type="submit" class="btn-filter btn-filter-primary">
<button type="submit" class="btn-filter btn-filter-primary supplier-action-button is-primary">
<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">
class="btn-filter btn-filter-secondary supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
重置
</a>
<button type="button" class="btn-filter btn-filter-secondary supplier-action-button" data-product-price-edit-open>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
编辑
</button>
</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>
@include('business.pages.supplier-payables.maintenance-import-modal', [
'supplierImportAction' => route('business.supplier-payables.product-prices.import', ['company_id' => company_id()]) . (request()->getQueryString() ? '?' . request()->getQueryString() : ''),
'supplierTemplateUrl' => route('business.supplier-payables.product-prices.template', ['company_id' => company_id()]),
])
@if ($errors->any() && !$errors->has('import'))
<div class="product-price-error" role="alert">
@ -521,14 +503,32 @@
<th>审批结果</th>
<th>流程标题</th>
<th>流程完成时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@forelse ($productPrices ?? [] as $index => $item)
<tr>
<tr
data-product-price-row
data-action="{{ !empty($item->upstream_key) ? 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 ?? '' }}"
tabindex="0"
aria-selected="false">
<td class="mono">{{ ($productPrices->currentPage() - 1) * $productPrices->perPage() + $index + 1 }}</td>
<td class="mono">{{ $item->barcode ?? '-' }}</td>
<td class="mono">
{{ $item->barcode ?? '-' }}
@if ((int) ($item->is_overridden ?? 0) === 1)
<span class="product-price-maintained">已维护</span>
@endif
</td>
<td class="mono">{{ $item->spu ?? '-' }}</td>
<td>{{ $item->product_name ?? '-' }}</td>
<td>{{ $item->category ?? '-' }}</td>
@ -554,39 +554,10 @@
@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="16" class="text-center py-12">
<td colspan="15" class="text-center py-12">
<span class="material-icons-outlined text-4xl mb-2 block">inventory_2</span>
@if ($productPriceError)
数据读取失败,请稍后重试。
@ -714,73 +685,76 @@
<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]');
const editOpen = document.querySelector('[data-product-price-edit-open]');
const rows = document.querySelectorAll('[data-product-price-row]');
let selectedRow = null;
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;
editForm?.reset();
if (editForm) editForm.action = '';
returnFocus?.focus();
};
document.querySelectorAll('[data-product-price-edit]').forEach((button) => {
button.addEventListener('click', () => {
if (!modal || !editForm) {
return;
const selectRow = (row) => {
selectedRow?.classList.remove('product-price-row-selected');
selectedRow?.setAttribute('aria-selected', 'false');
selectedRow = row;
selectedRow.classList.add('product-price-row-selected');
selectedRow.setAttribute('aria-selected', 'true');
};
const openEditor = () => {
if (!selectedRow?.dataset.action) {
window.alert('请先选择一条记录');
return;
}
if (!modal || !editForm) {
return;
}
returnFocus = editOpen;
editForm.action = selectedRow.dataset.action;
const values = {
barcode: selectedRow.dataset.barcode,
spu: selectedRow.dataset.spu,
product_name: selectedRow.dataset.productName,
category: selectedRow.dataset.category,
production_status: selectedRow.dataset.productionStatus,
minimum_price: selectedRow.dataset.minimumPrice,
price_limit: selectedRow.dataset.priceLimit,
purchase_price: selectedRow.dataset.purchasePrice,
shipping_fee: selectedRow.dataset.shippingFee,
jingxi_limit_price: selectedRow.dataset.jingxiLimitPrice,
};
Object.entries(values).forEach(([name, value]) => {
const input = editForm.elements.namedItem(name);
if (input) {
input.value = value || '';
}
});
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,
};
modal.hidden = false;
editForm.elements.namedItem('barcode')?.focus();
};
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();
rows.forEach((row) => {
row.addEventListener('click', () => selectRow(row));
row.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
selectRow(row);
});
});
editOpen?.addEventListener('click', openEditor);
modal?.querySelectorAll('[data-product-price-close]').forEach((button) => {
button.addEventListener('click', closeModal);

View File

@ -1,7 +1,7 @@
<div id="return-reconciliation-app" class="return-reconciliation-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">对账总金额</div>
<div class="text-2xl font-bold text-gray-800">@{{ formatMoney(summary.totalAmount) }}</div>
@ -29,8 +29,7 @@
start-placeholder="发货开始" end-placeholder="发货结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -39,7 +38,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left" show-overflow-tooltip></el-table-column>
<el-table-column prop="supplier" label="明细供应商" width="110" show-overflow-tooltip></el-table-column>

View File

@ -6,7 +6,7 @@
</div>
{{-- 统计卡片 --}}
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">未定价退货金额</div>
<div class="text-2xl font-bold text-orange-500">@{{ formatMoney(summary.totalAmount) }}</div>
@ -30,8 +30,7 @@
start-placeholder="日期开始" end-placeholder="日期结束" size="small"
style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -40,7 +39,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column prop="send_time" label="发货时间" width="110" fixed="left"></el-table-column>
<el-table-column prop="supplier" label="明细供应商" width="120" show-overflow-tooltip></el-table-column>
<el-table-column prop="barcode" label="条形码" width="160" show-overflow-tooltip></el-table-column>

View File

@ -1,7 +1,7 @@
<div id="sample-deduction-app" class="sample-deduction-page">
<div v-cloak>
{{-- 统计卡片 --}}
<div class="grid grid-cols-3 gap-4 mb-6">
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div class="bg-white rounded-xl border border-gray-100 p-5 shadow-sm">
<div class="text-sm text-gray-500 mb-1">合计扣款金额</div>
<div class="text-2xl font-bold text-red-600">@{{ formatMoney(summary.totalAmount) }}</div>
@ -27,8 +27,7 @@
start-placeholder="开始" end-placeholder="结束" size="small"
style="width: 240px" value-format="yyyy-MM-dd" @change="onFilterChange">
</el-date-picker>
<el-button size="small" type="primary" @click="onFilterChange">查询</el-button>
<el-button size="small" @click="onReset">重置</el-button>
@include('business.pages.supplier-payables.maintenance-actions')
</div>
</div>
@ -37,7 +36,7 @@
<div v-if="loading" class="flex items-center justify-center py-20">
<span class="text-gray-400">加载中...</span>
</div>
<el-table v-else :data="tableData" stripe border style="width: 100%" size="small" height="600">
<el-table v-else :data="tableData" stripe border highlight-current-row @current-change="maintenanceSelect" style="width: 100%" size="small" height="600">
<el-table-column type="index" label="#" width="50" fixed="left"></el-table-column>
<el-table-column prop="finish_time" label="日期" width="110" fixed="left"></el-table-column>
<el-table-column prop="department" label="部门" width="200" show-overflow-tooltip></el-table-column>

View File

@ -4,6 +4,7 @@
$supplierPayableError = $content['supplierPayableError'] ?? null;
$supplierPayableStatus = data_get($content, 'supplierPayableStatus.X-Caiwu-Data-Status');
$money = static fn (mixed $value): string => $value === null ? '-' : number_format((float) $value, 2);
$reportActions = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/supplier-payables-report');
@endphp
<div class="supplier-payables-report-page">
@ -54,6 +55,7 @@
.supplier-payables-report-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@ -213,20 +215,34 @@
<label for="supplier-payables-report-status">结算方式</label>
<input id="supplier-payables-report-status" name="settlement_method" type="text" value="{{ $supplierPayableFilters['settlement_method'] ?? '' }}" placeholder="请输入结算方式">
</div>
<div class="supplier-payables-report-actions">
<button type="submit" class="supplier-payables-report-button is-primary">
<div class="supplier-payables-report-actions supplier-page-actions">
<button type="button" class="supplier-payables-report-button supplier-action-button" data-supplier-import-open>
<span class="material-icons-outlined" aria-hidden="true">upload_file</span><span>导入</span>
</button>
<a href="{{ $reportActions . '/export?' . http_build_query(request()->except('page')) }}" class="supplier-payables-report-button supplier-action-button">
<span class="material-icons-outlined" aria-hidden="true">download</span><span>导出</span>
</a>
<button type="submit" class="supplier-payables-report-button supplier-action-button is-primary">
<span class="material-icons-outlined" aria-hidden="true">search</span>
<span>查询</span>
</button>
<a
href="{{ route('business.pages.show', ['section' => $section['slug'], 'page' => $page['slug']]) }}"
class="supplier-payables-report-button"
class="supplier-payables-report-button supplier-action-button"
>
<span class="material-icons-outlined" aria-hidden="true">refresh</span>
<span>重置</span>
</a>
<button type="button" class="supplier-payables-report-button supplier-action-button" data-supplier-maintenance-open>
<span class="material-icons-outlined" aria-hidden="true">edit</span>
<span>编辑</span>
</button>
</div>
</form>
@include('business.pages.supplier-payables.maintenance-import-modal', [
'supplierImportAction' => $reportActions . '/import',
'supplierTemplateUrl' => $reportActions . '/template',
])
@if ($supplierPayableError)
<div class="supplier-payables-report-alert" role="alert">{{ $supplierPayableError }}</div>
@ -243,7 +259,7 @@
<col style="width: 9rem">
<col style="width: 8rem">
<col style="width: 8rem">
<col style="width: 12rem">
<col style="width: 5rem">
<col style="width: 8rem">
<col style="width: 8rem">
<col style="width: 8rem">
@ -318,7 +334,7 @@
</thead>
<tbody>
@forelse ($supplierPayables ?? [] as $index => $row)
<tr>
<tr data-supplier-maintenance-row data-key="{{ $row->_action_key }}" data-row="{!! e(json_encode($row, JSON_UNESCAPED_UNICODE)) !!}" tabindex="0" aria-selected="false">
<td>{{ $row->checker ?: '-' }}</td>
<td>{{ $row->settlement_method ?: '-' }}</td>
<td>{{ ($supplierPayables->currentPage() - 1) * $supplierPayables->perPage() + $index + 1 }}</td>
@ -381,3 +397,4 @@
</div>
</div>
</div>
@include('business.pages.supplier-payables.maintenance-blade-modal')

View File

@ -7,9 +7,11 @@ use App\Http\Controllers\Business\CourierPayableSummaries;
use App\Http\Controllers\Business\CourierSurchargeQuotes;
use App\Http\Controllers\Business\CourierSurchargeTypes;
use App\Http\Controllers\Business\ProductPrices;
use App\Http\Controllers\Business\SupplierPayableActions;
use Illuminate\Support\Facades\Route;
use Modules\EcommerceFinance\Http\Controllers\AccountBills;
use Modules\EcommerceFinance\Http\Controllers\AlipayBills;
use Modules\EcommerceFinance\Http\Controllers\PinduoduoAccountBills;
use Modules\EcommerceFinance\Http\Controllers\Api\GoodsReconciliations;
use Modules\EcommerceFinance\Http\Controllers\Api\SupplierPayables;
use Modules\EcommerceFinance\Http\Controllers\ThirdPartyWarehouseStorageSettlementExportController;
@ -26,6 +28,12 @@ Route::get('ecommerce-finance/alipay-bills/export', [AlipayBills::class, 'export
Route::get('ecommerce-finance/alipay-bills/{bill}', [AlipayBills::class, 'show'])
->name('ecommerce-finance.alipay-bills.show');
Route::get('ecommerce-finance/account-bills/type/pinduoduo', [PinduoduoAccountBills::class, 'index'])
->name('ecommerce-finance.pinduoduo-account-bills.index');
Route::get('ecommerce-finance/account-bills/type/pinduoduo/filter-options', [PinduoduoAccountBills::class, 'filterOptions'])
->name('ecommerce-finance.pinduoduo-account-bills.filter-options');
Route::get('ecommerce-finance/account-bills/type/pinduoduo/export', [PinduoduoAccountBills::class, 'export'])
->name('ecommerce-finance.pinduoduo-account-bills.export');
Route::get('ecommerce-finance/account-bills/type/{bill_type}', [AccountBills::class, 'index'])
->name('ecommerce-finance.account-bills.index');
Route::get('ecommerce-finance/account-bills/type/{bill_type}/filter-options', [AccountBills::class, 'filterOptions'])
@ -138,12 +146,34 @@ Route::prefix('workspace/supplier-payables/product-prices')
->middleware('business.page:supplier-payables,product-prices')
->group(function () {
Route::get('export', [ProductPrices::class, 'export'])->name('export');
Route::get('template', [ProductPrices::class, 'template'])->name('template');
Route::post('import', [ProductPrices::class, 'import'])->middleware('import')->name('import');
Route::patch('{upstreamKey}', [ProductPrices::class, 'update'])
->where('upstreamKey', '[a-f0-9]{64}')
->name('update');
});
$supplierPayableActionPages = [
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
'compensation-details', 'advance-details', 'sample-deductions',
'delivery-unpriced', 'return-unpriced', 'other-adjustments',
'dropshipping-express', 'board-deductions', 'supplier-payables-report',
];
foreach ($supplierPayableActionPages as $dataset) {
Route::prefix('workspace/supplier-payables/actions/' . $dataset)
->middleware('business.page:supplier-payables,' . $dataset)
->group(function () use ($dataset) {
Route::get('definition', [SupplierPayableActions::class, 'definition'])->defaults('dataset', $dataset);
Route::get('export', [SupplierPayableActions::class, 'export'])->defaults('dataset', $dataset);
Route::get('template', [SupplierPayableActions::class, 'template'])->defaults('dataset', $dataset);
Route::post('import', [SupplierPayableActions::class, 'import'])->middleware('import')->defaults('dataset', $dataset);
Route::patch('{recordKey}', [SupplierPayableActions::class, 'update'])
->where('recordKey', '[A-Za-z0-9:_-]+')
->defaults('dataset', $dataset);
});
}
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

@ -39,6 +39,7 @@ trait CreatesApplication
// the shared production mirror.
config([
'database.connections.business_mirror' => config('database.connections.sqlite'),
'database.supplier_payable_maintenance_connection' => 'sqlite',
'upstream_mirrors.connection' => 'business_mirror',
]);
$app->make(DatabaseManager::class)->purge('business_mirror');

View File

@ -4,7 +4,7 @@ namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Mockery;
use Modules\EcommerceFinance\Repositories\StoreReceivingPinduoduoBillMirrorRepository;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use RuntimeException;
use Tests\Feature\FeatureTestCase;
@ -20,8 +20,8 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
'end' => '2026-09-10',
'keyword' => '260909',
];
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->with(
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
$repository->shouldReceive('paginateAccountBills')->once()->with(
$filters, 'occurred_at', 'desc', 2, 20,
)->andReturn([
'current_page' => 2,
@ -42,7 +42,7 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
]],
'summary' => ['income' => 91.64, 'expense' => 0.0, 'balance' => 91.64, 'count' => 41],
]);
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
$this->withExceptionHandling()
->loginAs()
@ -61,19 +61,19 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
public function test_filter_options_and_selected_column_export_use_the_same_mirror(): void
{
$this->allowPage();
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldReceive('filterOptions')->once()->andReturn([
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
$repository->shouldReceive('accountBillFilterOptions')->once()->andReturn([
'shops' => ['拼多多-A店', '拼多多-B店'],
'bill_items' => ['交易收入', '技术服务费'],
]);
$repository->shouldReceive('exportRows')->once()->with(
$repository->shouldReceive('exportAccountBills')->once()->with(
['store_name' => '拼多多-A店'], 'record_code', 'asc',
)->andReturn([[
'record_code' => '380425163199',
'store_name' => '拼多多-A店',
'payload' => ['finance_type' => '交易收入'],
]]);
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')
@ -103,25 +103,25 @@ class PinduoduoReceivingBillsTest extends FeatureTestCase
public function test_unready_mirror_returns_a_clear_retryable_response(): void
{
$this->allowPage();
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldReceive('paginate')->once()->andThrow(
new RuntimeException('store_receiving_alipay_bill_mirror_not_ready')
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
$repository->shouldReceive('paginateAccountBills')->once()->andThrow(
new RuntimeException('pinduoduo_authoritative_bills_not_ready')
);
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
$this->loginAs()
->withHeader('X-Requested-With', 'XMLHttpRequest')
->get(route('ecommerce-finance.account-bills.index', ['bill_type' => 'pinduoduo']))
->assertStatus(503)
->assertJsonPath('message', '拼多多账户账单共享业务镜像正在首次同步,完成后将自动显示。');
->assertJsonPath('message', '拼多多完整货款账单尚未完成首次导入,当前不会回退到不完整的聚水潭辅助表。');
}
public function test_invalid_date_range_is_rejected_before_reading_the_mirror(): void
{
$this->allowPage();
$repository = Mockery::mock(StoreReceivingPinduoduoBillMirrorRepository::class);
$repository->shouldNotReceive('paginate');
$this->app->instance(StoreReceivingPinduoduoBillMirrorRepository::class, $repository);
$repository = Mockery::mock(PinduoduoSettlementBillRepository::class);
$repository->shouldNotReceive('paginateAccountBills');
$this->app->instance(PinduoduoSettlementBillRepository::class, $repository);
$this->withExceptionHandling()
->loginAs()

View File

@ -7,6 +7,7 @@ use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\ProductPriceMirrorRepository;
use Modules\EcommerceFinance\Services\ProductPriceWorkbookImporter;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Tests\Feature\FeatureTestCase;
class ProductPriceActionsTest extends FeatureTestCase
@ -161,4 +162,30 @@ class ProductPriceActionsTest extends FeatureTestCase
]) . '?barcode=SKU-1&page=1')
->assertSessionHas('success', '导入完成:新增维护 2 条,更新维护 3 条。');
}
public function test_template_downloads_an_xlsx_with_product_price_import_headers(): void
{
$this->grantAdminPermission();
$response = $this->loginAs()->get(route(
'business.supplier-payables.product-prices.template',
['company_id' => $this->company->id],
));
$response->assertOk()->assertDownload();
$path = tempnam(sys_get_temp_dir(), 'product-price-template-');
file_put_contents($path, $response->streamedContent());
try {
$spreadsheet = IOFactory::load($path);
$headers = $spreadsheet->getSheet(0)->rangeToArray('A1:K1')[0];
$this->assertSame([
'记录标识', '商品编码', 'SPU', '商品名称', '类目', '生产状态',
'最低售价', '计算限价', '采购价', '运费', '京喜限价',
], $headers);
$this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle());
$spreadsheet->disconnectWorksheets();
} finally {
unlink($path);
}
}
}

View File

@ -0,0 +1,247 @@
<?php
namespace Tests\Feature\Business;
use App\Models\Auth\Permission;
use Carbon\Carbon;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use Mockery;
use Modules\EcommerceFinance\Repositories\SupplierPayableRepository;
use Modules\EcommerceFinance\Services\SupplierPayableMaintenanceRegistry;
use Modules\EcommerceFinance\Services\SupplierPayableOverrideStore;
use Modules\EcommerceFinance\Services\SupplierPayableWorkbookImporter;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Tests\Feature\FeatureTestCase;
class SupplierPayableActionsTest extends FeatureTestCase
{
protected function grantAdminPermission(): void
{
$this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail());
}
public function test_update_stores_company_scoped_override_and_audit_event(): void
{
$this->grantAdminPermission();
$response = $this->loginAs()->patchJson(
'/' . $this->company->id . '/workspace/supplier-payables/actions/compensation-details/id:88',
['values' => ['supplier' => '测试供应商', 'amount' => '128.5678', 'reason' => '质量赔付']],
);
$response->assertOk()->assertJsonPath('message', '保存成功。');
$this->assertDatabaseHas('supplier_payable_overrides', [
'company_id' => $this->company->id,
'dataset' => 'compensation-details',
'record_key' => 'id:88',
]);
$this->assertDatabaseHas('supplier_payable_override_events', [
'company_id' => $this->company->id,
'dataset' => 'compensation-details',
'record_key' => 'id:88',
'source' => 'manual',
]);
}
public function test_update_accepts_multi_segment_record_key(): void
{
$this->grantAdminPermission();
$response = $this->loginAs()->patchJson(
'/' . $this->company->id . '/workspace/supplier-payables/actions/payment-reconciliation/id:sale:3216852',
['values' => ['remark' => '编码路由保存测试']],
);
$response->assertOk()->assertJsonPath('message', '保存成功。');
$this->assertDatabaseHas('supplier_payable_overrides', [
'company_id' => $this->company->id,
'dataset' => 'payment-reconciliation',
'record_key' => 'id:sale:3216852',
]);
}
public function test_decorate_applies_only_matching_company_override(): void
{
$store = app(SupplierPayableOverrideStore::class);
$row = ['id' => 9, 'supplier' => '原供应商', 'amount' => 10];
$store->save('compensation-details', 'id:9', ['supplier' => '公司一'], (int) $this->company->id, null);
$store->save('compensation-details', 'id:9', ['supplier' => '公司二'], (int) $this->company->id + 1, null);
$decorated = $store->decorate('compensation-details', [$row], (int) $this->company->id);
$this->assertSame('公司一', $decorated[0]['supplier']);
$this->assertTrue($decorated[0]['_overridden']);
}
public function test_numeric_fields_reject_more_than_four_decimal_places(): void
{
$this->expectException(ValidationException::class);
app(SupplierPayableMaintenanceRegistry::class)->normalize(
'supplier-payables-report',
['should_pay' => '12.12345'],
);
}
public function test_csv_import_creates_override_and_audit_event(): void
{
$file = UploadedFile::fake()->createWithContent(
'compensation-details.csv',
"\xEF\xBB\xBF记录标识,供应商,赔付金额,赔付原因\nid:91,导入供应商,128.5678,导入原因\n",
);
$result = app(SupplierPayableWorkbookImporter::class)->import(
'compensation-details',
$file,
(int) $this->company->id,
(int) $this->user->id,
);
$this->assertSame(['created' => 1, 'updated' => 0, 'rows' => 1], $result);
$this->assertDatabaseHas('supplier_payable_overrides', [
'company_id' => $this->company->id,
'dataset' => 'compensation-details',
'record_key' => 'id:91',
'values' => '{"supplier":"导入供应商","amount":"128.5678","reason":"导入原因"}',
]);
$this->assertDatabaseHas('supplier_payable_override_events', [
'company_id' => $this->company->id,
'dataset' => 'compensation-details',
'record_key' => 'id:91',
'source' => 'import',
]);
}
public function test_csv_import_rejects_duplicate_record_keys_without_writing(): void
{
$file = UploadedFile::fake()->createWithContent(
'duplicate.csv',
"记录标识,供应商,赔付金额,赔付原因\nid:92,供应商一,10,原因一\nid:92,供应商二,20,原因二\n",
);
try {
app(SupplierPayableWorkbookImporter::class)->import(
'compensation-details',
$file,
(int) $this->company->id,
(int) $this->user->id,
);
$this->fail('重复记录标识应当拒绝导入。');
} catch (ValidationException $exception) {
$this->assertSame(['第 3 行“记录标识”重复。'], $exception->errors()['import']);
}
$this->assertDatabaseMissing('supplier_payable_overrides', [
'company_id' => $this->company->id,
'dataset' => 'compensation-details',
'record_key' => 'id:92',
]);
}
public function test_import_rejects_damaged_workbook(): void
{
$file = UploadedFile::fake()->createWithContent('damaged.xlsx', 'not a workbook');
try {
app(SupplierPayableWorkbookImporter::class)->import(
'compensation-details',
$file,
(int) $this->company->id,
(int) $this->user->id,
);
$this->fail('损坏的工作簿应当拒绝导入。');
} catch (ValidationException $exception) {
$this->assertSame(
['无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。'],
$exception->errors()['import'],
);
}
}
public function test_export_downloads_utf8_csv_and_escapes_formula_values(): void
{
$this->grantAdminPermission();
Carbon::setTestNow('2026-09-17 12:34:56');
$repository = Mockery::mock(SupplierPayableRepository::class);
$repository->shouldReceive('compensationDetails')
->once()
->with([], 1, 100)
->andReturn([
'data' => [[
'id' => 500,
'month' => '2026-09',
'supplier' => '=2+2',
'amount' => '18.5000',
'reason' => '+SUM(A1:A2)',
'remark' => '测试备注',
]],
'total' => 1,
]);
$this->app->instance(SupplierPayableRepository::class, $repository);
$response = $this->loginAs()->get(
'/' . $this->company->id . '/workspace/supplier-payables/actions/compensation-details/export',
);
$response->assertOk()->assertDownload('_20260917_123456.csv');
$this->assertStringContainsString(
"filename*=utf-8''" . rawurlencode('赔偿明细_20260917_123456.csv'),
(string) $response->headers->get('content-disposition'),
);
$content = $response->streamedContent();
$this->assertStringStartsWith("\xEF\xBB\xBF", $content);
$lines = preg_split('/\r\n|\n|\r/', substr($content, 3), -1, PREG_SPLIT_NO_EMPTY);
$this->assertSame(
['记录标识', '日期', '供应商', '赔付金额', '赔付原因', '备注'],
str_getcsv($lines[0]),
);
$this->assertSame(
['id:500', '2026-09', "\t=2+2", '18.5000', "\t+SUM(A1:A2)", '测试备注'],
str_getcsv($lines[1]),
);
}
public function test_each_supplier_payable_page_downloads_an_xlsx_template_with_its_import_headers(): void
{
$this->grantAdminPermission();
$registry = app(SupplierPayableMaintenanceRegistry::class);
$datasets = [
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
'compensation-details', 'advance-details', 'sample-deductions',
'delivery-unpriced', 'return-unpriced', 'other-adjustments',
'dropshipping-express', 'board-deductions', 'supplier-payables-report',
];
foreach ($datasets as $dataset) {
$response = $this->loginAs()->get(
'/' . $this->company->id . '/workspace/supplier-payables/actions/' . $dataset . '/template',
);
$response->assertOk()->assertDownload();
$this->assertSame(
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
$response->headers->get('content-type'),
);
$path = tempnam(sys_get_temp_dir(), 'supplier-payable-template-');
file_put_contents($path, $response->streamedContent());
try {
$spreadsheet = IOFactory::load($path);
$headers = $spreadsheet->getSheet(0)->rangeToArray(
'A1:' . $spreadsheet->getSheet(0)->getHighestColumn() . '1',
)[0];
$this->assertSame(
array_merge(['记录标识'], array_column($registry->fields($dataset), 'label')),
$headers,
$dataset,
);
$this->assertSame('填写说明', $spreadsheet->getSheet(1)->getTitle());
$spreadsheet->disconnectWorksheets();
} finally {
unlink($path);
}
}
}
}

View File

@ -20,6 +20,70 @@ class SupplierPayablePagesTest extends FeatureTestCase
);
}
public function testEverySupplierPayablePageExposesTheFiveRequestedActions(): void
{
$this->grantAdminPermission();
$pages = [
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
'compensation-details', 'advance-details', 'sample-deductions',
'other-adjustments', 'delivery-unpriced', 'return-unpriced',
'dropshipping-express', 'board-deductions', 'supplier-payables-report',
];
foreach ($pages as $page) {
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-payables',
'page' => $page,
]));
$response->assertOk();
foreach (['导入', '导出', '编辑', '查询', '重置'] as $action) {
$response->assertSeeText($action);
}
$response->assertSeeText('下载导入模板');
$response->assertSee('/template', false);
preg_match_all('/class="[^"]*supplier-action-button/', $response->getContent(), $actionButtons);
$this->assertCount(5, $actionButtons[0]);
}
}
public function testSupplierPayableEditorOverridesTheGlobalMultipartRequestType(): void
{
$source = file_get_contents(resource_path('assets/js/views/ecommerce-finance/supplier-payable-maintenance.js'));
$dialog = file_get_contents(resource_path('views/business/pages/supplier-payables/maintenance-toolbar.blade.php'));
$this->assertIsString($source);
$this->assertStringContainsString("'Content-Type': 'application/json'", $source);
$this->assertStringContainsString('maintenanceEditSelected()', $source);
$this->assertStringContainsString('v-if="maintenance.row"', $dialog);
}
public function testVueTablesRequireASelectedRowBeforeToolbarEditing(): void
{
$this->grantAdminPermission();
$pages = [
'payment-reconciliation', 'return-reconciliation', 'invoice-deductions',
'compensation-details', 'advance-details', 'sample-deductions',
'other-adjustments', 'delivery-unpriced', 'return-unpriced',
'dropshipping-express',
];
foreach ($pages as $page) {
$response = $this->loginAs()->get(route('business.pages.show', [
'section' => 'supplier-payables',
'page' => $page,
]));
$content = $response->getContent();
$response->assertOk();
$this->assertStringContainsString('@current-change="maintenanceSelect"', $content);
$this->assertStringContainsString('@click="maintenanceEditSelected"', $content);
$this->assertStringNotContainsString(':disabled="!maintenance.fields.length', $content);
$this->assertStringNotContainsString('maintenanceOpen(scope.row)', $content);
$this->assertLessThan(strpos($content, '@click="maintenanceEditSelected"'), strpos($content, '重置'));
}
}
public function testDeliveryUnpricedPageUsesItsOwnTwentyColumnTable()
{
$this->grantAdminPermission();
@ -164,15 +228,20 @@ class SupplierPayablePagesTest extends FeatureTestCase
->assertSee('已同意')
->assertSeeText('导入')
->assertSeeText('导出')
->assertSeeText('下载导入模板')
->assertSeeText('查询')
->assertSeeText('重置')
->assertSee('data-product-price-edit', false)
->assertSee('data-product-price-edit-open', false)
->assertSee('data-product-price-row', false)
->assertDontSee('class="product-price-edit-button"', false)
->assertSeeText('编辑商品价格')
->assertDontSee('是否采购降本');
preg_match_all('/<th(?:\s|>)/', $response->getContent(), $headers);
preg_match_all('/class="[^"]*supplier-action-button/', $response->getContent(), $actionButtons);
$this->assertCount(16, $headers[0]);
$this->assertCount(15, $headers[0]);
$this->assertCount(5, $actionButtons[0]);
}
public function testProductPricesPageDoesNotTreatUninitializedMirrorAsEmptyData(): void

View File

@ -0,0 +1,348 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use Modules\EcommerceFinance\Services\PinduoduoSettlementBillImporter;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use RuntimeException;
use Tests\TestCase;
class PinduoduoSettlementBillImporterTest extends TestCase
{
/** @var list<string> */
private array $files = [];
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$this->createTables();
}
protected function tearDown(): void
{
foreach ($this->files as $file) {
@unlink($file);
}
parent::tearDown();
}
public function test_it_imports_current_jushengsuan_headers_keeps_all_accounts_and_is_idempotent(): void
{
$this->insertActiveRow('OLD-IN-PERIOD', '2026-08-15 12:00:00');
$this->insertActiveRow('KEEP-OUTSIDE-PERIOD', '2026-07-31 23:59:59');
$path = $this->xlsx([
$this->row('REC-INCOME', '2026-08-01 00:00:19', '100.00', '0', '100.00'),
$this->row('REC-EXPENSE', '2026-08-02 12:00:00', '0', '25.00', '-25.00', '拼多多货款账户', '退款'),
$this->row('REC-ZERO', '2026-08-03 12:00:00', '0', '0', '0'),
$this->row('REC-MARKETING', '2026-08-04 12:00:00', '0', '3.00', '-3.00', '拼多多营销账户'),
]);
$importer = app(PinduoduoSettlementBillImporter::class);
$result = $importer->import(
$path,
'2026-08-01',
'2026-08-31',
4,
'100.00',
'28.00',
100,
);
$this->assertSame('succeeded', $result['status']);
$this->assertSame(4, $result['row_count']);
$this->assertSame(0, $result['excluded_account_rows']);
$this->assertSame(1, $result['store_count']);
$this->assertSame(2, $result['bill_item_count']);
$this->assertSame([
'拼多多货款账户' => 3,
'拼多多营销账户' => 1,
], $result['account_type_counts']);
$this->assertSame('100.00', $result['income_total']);
$this->assertSame('28.00', $result['expense_total']);
$this->assertSame('72.00', $result['net_total']);
$this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'OLD-IN-PERIOD'], 'business_mirror');
$this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'KEEP-OUTSIDE-PERIOD'], 'business_mirror');
$this->assertDatabaseHas('pdd_settlement_bill_rows', [
'record_code' => 'REC-MARKETING',
'account_name' => '拼多多营销账户',
], 'business_mirror');
$repository = app(PinduoduoSettlementBillRepository::class);
$this->assertTrue($repository->ready());
$this->assertSame([
'income_total' => 100.0,
'expense_total' => 28.0,
'cnt' => 4,
], $repository->summarize(null, '2026-08-01', '2026-08-31', null));
$this->assertSame(1, $repository->paginate(null, '2026-08-01', '2026-08-31', 'income', 1, 25)['total']);
$this->assertSame(2, $repository->paginate(null, '2026-08-01', '2026-08-31', 'expense', 1, 25)['total']);
$zero = $repository->paginate(null, '2026-08-03', '2026-08-03', null, 1, 25);
$this->assertSame(0, $zero['rows'][0]['flowType']);
$unchanged = $importer->import(
$path,
'2026-08-01',
'2026-08-31',
4,
'100.00',
'28.00',
100,
);
$this->assertSame('unchanged', $unchanged['status']);
$this->assertSame(5, DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->count());
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('本次账期或权威控制数与历史记录不一致');
$importer->import($path, '2026-08-01', '2026-08-31', 5, '100.00', '28.00', 100);
}
public function test_control_mismatch_does_not_replace_the_active_period(): void
{
$this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00');
$path = $this->xlsx([
$this->row('NEW-ROW', '2026-08-16 12:00:00', '10.00', '0', '10.00'),
]);
try {
app(PinduoduoSettlementBillImporter::class)->import(
$path,
'2026-08-01',
'2026-08-31',
2,
'10.00',
'0.00',
100,
);
$this->fail('控制数不一致时必须拒绝激活。');
} catch (RuntimeException $exception) {
$this->assertStringContainsString('权威控制数不一致', $exception->getMessage());
}
$this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
$this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'NEW-ROW'], 'business_mirror');
$this->assertSame('failed', DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('status'));
$this->assertSame(2, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('expected_row_count'));
$this->assertSame(1, DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->value('row_count'));
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count());
}
public function test_duplicate_record_codes_are_rejected_without_replacing_active_rows(): void
{
$this->insertActiveRow('ACTIVE-ROW', '2026-08-15 12:00:00');
$path = $this->xlsx([
$this->row('DUPLICATE', '2026-08-16 12:00:00', '10.00', '0', '10.00'),
$this->row('DUPLICATE', '2026-08-17 12:00:00', '20.00', '0', '20.00'),
]);
$failure = null;
try {
app(PinduoduoSettlementBillImporter::class)->import(
$path,
'2026-08-01',
'2026-08-31',
2,
'30.00',
'0.00',
100,
);
} catch (\Throwable $exception) {
$failure = $exception;
}
$this->assertNotNull($failure, '重复记录编码必须拒绝导入。');
$this->assertDatabaseHas('pdd_settlement_bill_rows', ['record_code' => 'ACTIVE-ROW'], 'business_mirror');
$this->assertDatabaseMissing('pdd_settlement_bill_rows', ['record_code' => 'DUPLICATE'], 'business_mirror');
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count());
}
public function test_out_of_period_and_inconsistent_amount_rows_are_rejected(): void
{
$outOfPeriod = $this->xlsx([
$this->row('OUTSIDE', '2026-09-01 00:00:00', '10.00', '0', '10.00'),
]);
$badAmount = $this->xlsx([
$this->row('BAD-AMOUNT', '2026-08-10 00:00:00', '10.00', '2.00', '9.00'),
]);
$importer = app(PinduoduoSettlementBillImporter::class);
foreach ([
[$outOfPeriod, '发生时间超出指定账期'],
[$badAmount, '收入、支出与金额不一致'],
] as [$path, $message]) {
try {
$importer->import($path, '2026-08-01', '2026-08-31', 1, '10.00', '0.00', 100);
$this->fail($message . '时必须拒绝导入。');
} catch (RuntimeException $exception) {
$this->assertStringContainsString($message, $exception->getMessage());
}
}
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->count());
$this->assertSame(0, DB::connection('business_mirror')->table('pdd_settlement_bill_staging')->count());
}
public function test_blank_account_type_is_rejected(): void
{
$path = $this->xlsx([
$this->row('BLANK-ACCOUNT', '2026-08-10 00:00:00', '10.00', '0', '10.00', ''),
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('缺少账户类型');
app(PinduoduoSettlementBillImporter::class)->import(
$path,
'2026-08-01',
'2026-08-31',
1,
'10.00',
'0.00',
100,
);
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void {
$table->uuid('generation')->primary();
$table->string('source_filename');
$table->char('source_sha256', 64)->unique();
$table->date('period_start');
$table->date('period_end');
$table->string('status', 32)->default('running');
$table->unsignedBigInteger('expected_row_count');
$table->decimal('expected_income_total', 20, 2);
$table->decimal('expected_expense_total', 20, 2);
$table->unsignedBigInteger('row_count')->default(0);
$table->unsignedBigInteger('excluded_account_rows')->default(0);
$table->unsignedInteger('store_count')->default(0);
$table->unsignedInteger('bill_item_count')->default(0);
$table->json('account_type_counts')->nullable();
$table->decimal('income_total', 20, 2)->default(0);
$table->decimal('expense_total', 20, 2)->default(0);
$table->decimal('net_total', 20, 2)->default(0);
$table->string('last_error_code', 120)->nullable();
$table->timestamp('completed_at')->nullable();
$table->timestamps();
});
$schema->create('pdd_settlement_bill_staging', function (Blueprint $table): void {
$table->bigIncrements('id');
$table->uuid('generation');
$this->billColumns($table);
$table->unique(['generation', 'record_code']);
});
$schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void {
$table->string('record_code', 191)->primary();
$this->billColumns($table, false);
$table->uuid('source_generation');
$table->char('source_sha256', 64);
$table->timestamp('imported_at');
});
}
private function billColumns(Blueprint $table, bool $includeRecordCode = true): void
{
if ($includeRecordCode) {
$table->string('record_code', 191);
}
$table->string('store_name')->nullable();
$table->string('custom_remark')->nullable();
$table->string('bill_item')->nullable();
$table->string('merchant_order_no', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->string('business_type')->nullable();
$table->string('business_description', 1000)->nullable();
$table->string('account_name')->nullable();
$table->dateTime('occurred_at');
$table->decimal('income_amount', 20, 2)->default(0);
$table->decimal('expense_amount', 20, 2)->default(0);
$table->decimal('signed_amount', 20, 2)->default(0);
$table->string('remark', 1000)->nullable();
$table->dateTime('source_updated_at')->nullable();
$table->string('source_modified_by')->nullable();
$table->unsignedBigInteger('source_row_number');
}
private function insertActiveRow(string $recordCode, string $occurredAt): void
{
DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->insert([
'record_code' => $recordCode,
'store_name' => '原有店铺',
'bill_item' => '原有流水',
'account_name' => PinduoduoSettlementBillImporter::ACCOUNT_NAME,
'occurred_at' => $occurredAt,
'income_amount' => 1,
'expense_amount' => 0,
'signed_amount' => 1,
'source_row_number' => 1,
'source_generation' => 'existing-generation',
'source_sha256' => str_repeat('a', 64),
'imported_at' => '2026-09-01 00:00:00',
]);
}
/** @return list<string> */
private function row(
string $recordCode,
string $occurredAt,
string $income,
string $expense,
string $signed,
string $account = PinduoduoSettlementBillImporter::ACCOUNT_NAME,
string $billItem = '技术服务费',
): array {
return [
$recordCode,
'拼多多-测试店铺',
'',
$billItem,
'ORDER-' . $recordCode,
'ONLINE-' . $recordCode,
$billItem,
'001|' . $billItem,
$account,
$occurredAt,
$income,
$expense,
$signed,
'-',
'2026-09-07 17:07:15',
'RPA',
];
}
/** @param list<list<string>> $rows */
private function xlsx(array $rows): string
{
$temporary = tempnam(sys_get_temp_dir(), 'pdd-authoritative-');
$path = $temporary . '.xlsx';
rename($temporary, $path);
$spreadsheet = new Spreadsheet();
$spreadsheet->getActiveSheet()->fromArray(array_merge([[
'记录编码', '店铺', '自定义备注', '账单项目', '商户订单号', '线上单号',
'财务类型', '业务描述', '类型', '发生时间', '收入金额', '支出金额', '金额',
'备注', '修改时间', '修改人',
]], $rows));
(new Xlsx($spreadsheet))->save($path);
$spreadsheet->disconnectWorksheets();
$this->files[] = $path;
return $path;
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Repositories\PinduoduoSettlementBillRepository;
use RuntimeException;
use Tests\TestCase;
class PinduoduoSettlementAccountBillsRepositoryTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.connections.business_mirror' => [
'driver' => 'sqlite',
'database' => ':memory:',
'prefix' => '',
],
'upstream_mirrors.connection' => 'business_mirror',
]);
DB::purge('business_mirror');
$this->createTables();
}
public function test_account_bill_page_reads_authoritative_rows_with_filters_and_totals(): void
{
$this->insertSucceededImport();
$this->insertRow('REC-INCOME', '拼多多一店', '交易收款', '2026-08-01 00:00:00', '100.00', '0.00', '100.00', 'M-100', 'O-100');
$this->insertRow('REC-EXPENSE', '拼多多一店', '退款', '2026-08-31 23:59:59', '0.00', '20.00', '-20.00', 'M-200', 'O-200');
$this->insertRow('REC-OTHER', '拼多多二店', '交易收款', '2026-08-15 12:00:00', '50.00', '0.00', '50.00', 'M-300', 'O-300');
$result = app(PinduoduoSettlementBillRepository::class)->paginateAccountBills([
'store_name' => '拼多多一店',
'start' => '2026-08-01',
'end' => '2026-08-31',
], 'occurred_at', 'desc', 1, 10);
$this->assertSame(2, $result['total']);
$this->assertSame(100.0, $result['summary']['income']);
$this->assertSame(20.0, $result['summary']['expense']);
$this->assertSame(80.0, $result['summary']['balance']);
$this->assertSame('REC-EXPENSE', $result['data'][0]['record_code']);
$this->assertSame('M-200', $result['data'][0]['order_no']);
$this->assertSame('O-200', $result['data'][0]['payload']['online_order_no']);
$this->assertSame('拼多多货款账户', $result['data'][0]['payload']['type']);
$this->assertSame(20.0, $result['data'][0]['amount']);
$this->assertSame(-20.0, $result['data'][0]['balance']);
$filtered = app(PinduoduoSettlementBillRepository::class)->paginateAccountBills([
'bill_item' => '退款',
'keyword' => 'O-200',
], 'record_code', 'asc', 1, 10);
$this->assertSame(1, $filtered['total']);
$this->assertSame('REC-EXPENSE', $filtered['data'][0]['record_code']);
$options = app(PinduoduoSettlementBillRepository::class)->accountBillFilterOptions();
$this->assertSame(['拼多多一店', '拼多多二店'], $options['shops']);
$this->assertSame(['交易收款', '退款'], $options['bill_items']);
$export = iterator_to_array(app(PinduoduoSettlementBillRepository::class)->exportAccountBills([
'bill_item' => '退款',
], 'record_code', 'asc'));
$this->assertCount(1, $export);
$this->assertSame('REC-EXPENSE', $export[0]['record_code']);
}
public function test_account_bill_page_fails_closed_until_a_successful_import_exists(): void
{
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('pinduoduo_authoritative_bills_not_ready');
app(PinduoduoSettlementBillRepository::class)->paginateAccountBills(
[],
'occurred_at',
'desc',
1,
10,
);
}
private function createTables(): void
{
$schema = Schema::connection('business_mirror');
$schema->create('pdd_settlement_bill_imports', function (Blueprint $table): void {
$table->uuid('generation')->primary();
$table->string('status', 32);
});
$schema->create('pdd_settlement_bill_rows', function (Blueprint $table): void {
$table->string('record_code', 191)->primary();
$table->string('store_name')->nullable();
$table->string('custom_remark')->nullable();
$table->string('bill_item')->nullable();
$table->string('merchant_order_no', 191)->nullable();
$table->string('online_order_no', 191)->nullable();
$table->string('business_type')->nullable();
$table->string('business_description', 1000)->nullable();
$table->string('account_name')->nullable();
$table->dateTime('occurred_at');
$table->decimal('income_amount', 20, 2);
$table->decimal('expense_amount', 20, 2);
$table->decimal('signed_amount', 20, 2);
$table->string('remark', 1000)->nullable();
$table->dateTime('source_updated_at')->nullable();
$table->string('source_modified_by')->nullable();
});
}
private function insertSucceededImport(): void
{
DB::connection('business_mirror')->table('pdd_settlement_bill_imports')->insert([
'generation' => '11111111-1111-1111-1111-111111111111',
'status' => 'succeeded',
]);
}
private function insertRow(
string $recordCode,
string $storeName,
string $billItem,
string $occurredAt,
string $income,
string $expense,
string $signed,
string $merchantOrderNo,
string $onlineOrderNo,
): void {
DB::connection('business_mirror')->table('pdd_settlement_bill_rows')->insert([
'record_code' => $recordCode,
'store_name' => $storeName,
'custom_remark' => '自定义备注',
'bill_item' => $billItem,
'merchant_order_no' => $merchantOrderNo,
'online_order_no' => $onlineOrderNo,
'business_type' => '货款',
'business_description' => '业务描述',
'account_name' => '拼多多货款账户',
'occurred_at' => $occurredAt,
'income_amount' => $income,
'expense_amount' => $expense,
'signed_amount' => $signed,
'remark' => '账单备注',
'source_updated_at' => '2026-09-01 08:00:00',
'source_modified_by' => '聚胜算',
]);
}
}