diff --git a/app/Console/Commands/ImportPinduoduoSettlementBills.php b/app/Console/Commands/ImportPinduoduoSettlementBills.php new file mode 100644 index 00000000..fd32f2cf --- /dev/null +++ b/app/Console/Commands/ImportPinduoduoSettlementBills.php @@ -0,0 +1,72 @@ +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; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index b293d8d6..1bc354b1 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -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' => '供应商应付数据暂时无法读取,请稍后重试。', ]; diff --git a/app/Http/Controllers/Business/ProductPrices.php b/app/Http/Controllers/Business/ProductPrices.php index d506478d..44a90f55 100644 --- a/app/Http/Controllers/Business/ProductPrices.php +++ b/app/Http/Controllers/Business/ProductPrices.php @@ -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( diff --git a/app/Http/Controllers/Business/SupplierPayableActions.php b/app/Http/Controllers/Business/SupplierPayableActions.php new file mode 100644 index 00000000..520315f8 --- /dev/null +++ b/app/Http/Controllers/Business/SupplierPayableActions.php @@ -0,0 +1,196 @@ +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>> */ + 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; + } +} diff --git a/config/database.php b/config/database.php index 8ba5dcc7..36321b8e 100644 --- a/config/database.php +++ b/config/database.php @@ -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' => [ diff --git a/database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php b/database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php new file mode 100644 index 00000000..22fc7ad5 --- /dev/null +++ b/database/migrations/2026_09_17_000000_create_supplier_payable_override_tables.php @@ -0,0 +1,50 @@ +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'); + } +}; diff --git a/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php b/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php new file mode 100644 index 00000000..3d1f0fb9 --- /dev/null +++ b/database/migrations/2026_09_22_000000_create_pinduoduo_settlement_bill_tables.php @@ -0,0 +1,110 @@ +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'); + } +}; diff --git a/deploy/local-init.sh b/deploy/local-init.sh index 6dfa66b8..3b8b9b1a 100644 --- a/deploy/local-init.sh +++ b/deploy/local-init.sh @@ -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 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 45276cf0..2c90773c 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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: diff --git a/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php b/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php index dfa53775..9cc94795 100644 --- a/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php +++ b/modules/EcommerceFinance/Http/Controllers/Api/GoodsReconciliations.php @@ -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'], diff --git a/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php b/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php index c8401e4d..8d84c164 100644 --- a/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php +++ b/modules/EcommerceFinance/Http/Controllers/Api/SupplierPayables.php @@ -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'], diff --git a/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php new file mode 100644 index 00000000..c990561b --- /dev/null +++ b/modules/EcommerceFinance/Http/Controllers/PinduoduoAccountBills.php @@ -0,0 +1,178 @@ +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> */ + 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 $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, '当前账号没有店铺收款账户账单权限。'); + } +} diff --git a/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php new file mode 100644 index 00000000..b3922e20 --- /dev/null +++ b/modules/EcommerceFinance/Repositories/PinduoduoSettlementBillRepository.php @@ -0,0 +1,304 @@ +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,bill_items:list} */ + 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>,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 */ + 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> */ + 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 $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 */ + 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; + } +} diff --git a/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php b/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php index 96dfc4f1..7f3130d4 100644 --- a/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php +++ b/modules/EcommerceFinance/Repositories/SupplierPayableRepository.php @@ -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, diff --git a/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php b/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php new file mode 100644 index 00000000..0219e0f7 --- /dev/null +++ b/modules/EcommerceFinance/Services/PinduoduoSettlementBillImporter.php @@ -0,0 +1,709 @@ + ['记录编码'], + '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,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,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 */ + 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 $cells @return array */ + 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 */ + 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 */ + 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; + } +} diff --git a/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php b/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php index 48394b17..8d77feae 100644 --- a/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php +++ b/modules/EcommerceFinance/Services/ProductPriceWorkbookImporter.php @@ -39,6 +39,25 @@ class ProductPriceWorkbookImporter { } + /** @return array */ + 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 { diff --git a/modules/EcommerceFinance/Services/SupplierPayableImportTemplate.php b/modules/EcommerceFinance/Services/SupplierPayableImportTemplate.php new file mode 100644 index 00000000..837813c5 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableImportTemplate.php @@ -0,0 +1,89 @@ + $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); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierPayableMaintenanceRegistry.php b/modules/EcommerceFinance/Services/SupplierPayableMaintenanceRegistry.php new file mode 100644 index 00000000..df1d9b3f --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableMaintenanceRegistry.php @@ -0,0 +1,167 @@ + */ + 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(), + ]; + } +} diff --git a/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php b/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php new file mode 100644 index 00000000..30718531 --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableOverrideStore.php @@ -0,0 +1,107 @@ +> $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')); + } +} diff --git a/modules/EcommerceFinance/Services/SupplierPayableWorkbookImporter.php b/modules/EcommerceFinance/Services/SupplierPayableWorkbookImporter.php new file mode 100644 index 00000000..727e90cc --- /dev/null +++ b/modules/EcommerceFinance/Services/SupplierPayableWorkbookImporter.php @@ -0,0 +1,129 @@ +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))); + } +} diff --git a/resources/assets/js/views/ecommerce-finance/advance-details.js b/resources/assets/js/views/ecommerce-finance/advance-details.js index d6629fba..07bec1b8 100644 --- a/resources/assets/js/views/ecommerce-finance/advance-details.js +++ b/resources/assets/js/views/ecommerce-finance/advance-details.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/compensation-details.js b/resources/assets/js/views/ecommerce-finance/compensation-details.js index ae67fbbb..5d11d8eb 100644 --- a/resources/assets/js/views/ecommerce-finance/compensation-details.js +++ b/resources/assets/js/views/ecommerce-finance/compensation-details.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js b/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js index c5347cd6..4ad90b17 100644 --- a/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js +++ b/resources/assets/js/views/ecommerce-finance/delivery-unpriced.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/dropshipping-express.js b/resources/assets/js/views/ecommerce-finance/dropshipping-express.js index 8b022f91..daf41317 100644 --- a/resources/assets/js/views/ecommerce-finance/dropshipping-express.js +++ b/resources/assets/js/views/ecommerce-finance/dropshipping-express.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js b/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js index 28cefa94..43c3166b 100644 --- a/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js +++ b/resources/assets/js/views/ecommerce-finance/goods-reconciliations.js @@ -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 */ diff --git a/resources/assets/js/views/ecommerce-finance/invoice-deductions.js b/resources/assets/js/views/ecommerce-finance/invoice-deductions.js index a959757b..d7197a94 100644 --- a/resources/assets/js/views/ecommerce-finance/invoice-deductions.js +++ b/resources/assets/js/views/ecommerce-finance/invoice-deductions.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/other-adjustments.js b/resources/assets/js/views/ecommerce-finance/other-adjustments.js index 3108b4d0..aecdfef4 100644 --- a/resources/assets/js/views/ecommerce-finance/other-adjustments.js +++ b/resources/assets/js/views/ecommerce-finance/other-adjustments.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/return-reconciliations.js b/resources/assets/js/views/ecommerce-finance/return-reconciliations.js index 7f193bfa..76ab3686 100644 --- a/resources/assets/js/views/ecommerce-finance/return-reconciliations.js +++ b/resources/assets/js/views/ecommerce-finance/return-reconciliations.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/return-unpriced.js b/resources/assets/js/views/ecommerce-finance/return-unpriced.js index 44293c4c..13f56613 100644 --- a/resources/assets/js/views/ecommerce-finance/return-unpriced.js +++ b/resources/assets/js/views/ecommerce-finance/return-unpriced.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/sample-deductions.js b/resources/assets/js/views/ecommerce-finance/sample-deductions.js index 21e07f49..c5c0e436 100644 --- a/resources/assets/js/views/ecommerce-finance/sample-deductions.js +++ b/resources/assets/js/views/ecommerce-finance/sample-deductions.js @@ -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: [], diff --git a/resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js b/resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js new file mode 100644 index 00000000..e60dc514 --- /dev/null +++ b/resources/assets/js/views/ecommerce-finance/supplier-payable-maintenance.js @@ -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; + } + } +}; diff --git a/resources/views/business/pages/supplier-payables/advance-details.blade.php b/resources/views/business/pages/supplier-payables/advance-details.blade.php index b713bc12..a20fcb40 100644 --- a/resources/views/business/pages/supplier-payables/advance-details.blade.php +++ b/resources/views/business/pages/supplier-payables/advance-details.blade.php @@ -6,7 +6,7 @@ {{-- 统计卡片 --}} -
+
借支总金额
@{{ formatMoney(summary.totalAmount) }}
@@ -25,8 +25,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -35,7 +34,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/board-deductions.blade.php b/resources/views/business/pages/supplier-payables/board-deductions.blade.php index a17c48c9..aa49074b 100644 --- a/resources/views/business/pages/supplier-payables/board-deductions.blade.php +++ b/resources/views/business/pages/supplier-payables/board-deductions.blade.php @@ -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
@@ -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 @@
-
- + + 导出 + + - + 重置 +
+ @include('business.pages.supplier-payables.maintenance-import-modal', [ + 'supplierImportAction' => $boardActions . '/import', + 'supplierTemplateUrl' => $boardActions . '/template', + ]) @if ($boardDeductionError) @@ -231,7 +249,7 @@ @forelse ($boardDeductions ?? [] as $row) - + {{ $row->stock_in_date ?: '-' }} {{ $row->stock_out_date ?: '-' }} {{ $row->furniture_factory ?: '-' }} @@ -277,3 +295,4 @@
+@include('business.pages.supplier-payables.maintenance-blade-modal') diff --git a/resources/views/business/pages/supplier-payables/compensation-details.blade.php b/resources/views/business/pages/supplier-payables/compensation-details.blade.php index ddfce9dd..030e290d 100644 --- a/resources/views/business/pages/supplier-payables/compensation-details.blade.php +++ b/resources/views/business/pages/supplier-payables/compensation-details.blade.php @@ -46,8 +46,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions') @@ -56,7 +55,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php b/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php index adee8b68..46fb957e 100644 --- a/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php +++ b/resources/views/business/pages/supplier-payables/delivery-unpriced.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
未定价记录数
@{{ (summary.totalCount || 0).toLocaleString() }}
@@ -29,8 +29,7 @@ start-placeholder="日期开始" end-placeholder="日期结束" size="small" style="width: 260px" value-format="yyyy-MM-dd" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -39,7 +38,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php b/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php index 692745e2..86daeccd 100644 --- a/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php +++ b/resources/views/business/pages/supplier-payables/dropshipping-express.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
合计运费
@{{ formatMoney(summary.totalAmount) }}
@@ -23,8 +23,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -33,7 +32,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php b/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php index a7cd58eb..2fc0d9dd 100644 --- a/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php +++ b/resources/views/business/pages/supplier-payables/invoice-deductions.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
扣款总金额
@{{ formatMoney(summary.totalPrice) }}
@@ -20,8 +20,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -30,7 +29,7 @@
加载中...
- + diff --git a/resources/views/business/pages/supplier-payables/maintenance-actions.blade.php b/resources/views/business/pages/supplier-payables/maintenance-actions.blade.php new file mode 100644 index 00000000..3b72a117 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-actions.blade.php @@ -0,0 +1,10 @@ +
+ @include('business.pages.supplier-payables.maintenance-toolbar') + + 查询 + + + 重置 + + @include('business.pages.supplier-payables.maintenance-edit-button') +
diff --git a/resources/views/business/pages/supplier-payables/maintenance-blade-modal.blade.php b/resources/views/business/pages/supplier-payables/maintenance-blade-modal.blade.php new file mode 100644 index 00000000..26e7dee2 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-blade-modal.blade.php @@ -0,0 +1,109 @@ +@php + $maintenance = $content['supplierPayableMaintenance'] ?? ['dataset' => '', 'fields' => []]; + $maintenanceAction = url('/' . (company_id() ?: 1) . '/workspace/supplier-payables/actions/' . $maintenance['dataset']); +@endphp + + + +@once + +@endonce + +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/maintenance-edit-button.blade.php b/resources/views/business/pages/supplier-payables/maintenance-edit-button.blade.php new file mode 100644 index 00000000..2f9eb8dc --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-edit-button.blade.php @@ -0,0 +1,6 @@ + + 编辑 + diff --git a/resources/views/business/pages/supplier-payables/maintenance-import-modal.blade.php b/resources/views/business/pages/supplier-payables/maintenance-import-modal.blade.php new file mode 100644 index 00000000..1dbe9a30 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-import-modal.blade.php @@ -0,0 +1,96 @@ + + +@once + +@endonce + +@push('body_end') + +@endpush diff --git a/resources/views/business/pages/supplier-payables/maintenance-toolbar.blade.php b/resources/views/business/pages/supplier-payables/maintenance-toolbar.blade.php new file mode 100644 index 00000000..35d7e671 --- /dev/null +++ b/resources/views/business/pages/supplier-payables/maintenance-toolbar.blade.php @@ -0,0 +1,43 @@ + + 导入 + + + 导出 + + + +
+ + + 下载导入模板当前页面字段模板(Excel) + + + +
+ + 取消 + 开始导入 + +
+ + + + + + + + + + + 取消 + 保存 + + diff --git a/resources/views/business/pages/supplier-payables/navigation.blade.php b/resources/views/business/pages/supplier-payables/navigation.blade.php index ef119914..3fece263 100644 --- a/resources/views/business/pages/supplier-payables/navigation.blade.php +++ b/resources/views/business/pages/supplier-payables/navigation.blade.php @@ -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; } } @include('business.pages.supplier-payables.local-navigation') diff --git a/resources/views/business/pages/supplier-payables/other-adjustments.blade.php b/resources/views/business/pages/supplier-payables/other-adjustments.blade.php index 953ddea8..3faea24b 100644 --- a/resources/views/business/pages/supplier-payables/other-adjustments.blade.php +++ b/resources/views/business/pages/supplier-payables/other-adjustments.blade.php @@ -1,7 +1,7 @@
{{-- 统计卡片 --}} -
+
补款总金额
@{{ formatMoney(summary.totalAmount) }}
@@ -20,8 +20,7 @@ start-placeholder="月份开始" end-placeholder="月份结束" size="small" style="width: 240px" value-format="yyyy-MM" @change="onFilterChange"> - 查询 - 重置 + @include('business.pages.supplier-payables.maintenance-actions')
@@ -30,7 +29,7 @@
加载中...
- +